Compare commits
28 Commits
feat/dedup
...
c612bbdfd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 | ||
|
|
b703684285 | ||
|
|
a792d9a182 | ||
|
|
d7ec2a8507 | ||
|
|
cb83b09b2d | ||
|
|
bb105f5365 | ||
|
|
caafae15dd | ||
|
|
46c7389930 | ||
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
cad55e3565 | ||
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 | ||
|
|
36b92eb827 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -28,10 +28,22 @@ docker-compose.override.yml
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||
/website/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
/.audit-screenshots/
|
||||
|
||||
# Migration script output (CSV reports, transcripts)
|
||||
.migration/
|
||||
|
||||
# Tool caches / runtime state
|
||||
/.claude/
|
||||
/.serena/
|
||||
/ruvector.db
|
||||
|
||||
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.
|
||||
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Client Deduplication and NocoDB Migration Design
|
||||
|
||||
**Status**: Design draft 2026-05-03 — pending approval.
|
||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
### 1.1 Why this exists
|
||||
|
||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
||||
|
||||
- **252 Interests rows** in NocoDB, against an estimated ~190–200 unique humans (~20–25% duplication rate).
|
||||
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
|
||||
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
|
||||
- **No Clients table.** The conflated structure is structural, not accidental.
|
||||
|
||||
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
|
||||
|
||||
### 1.2 Real duplicate patterns observed in the live data
|
||||
|
||||
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
|
||||
|
||||
| Pattern | Example rows | Signature |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
|
||||
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
|
||||
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
|
||||
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
|
||||
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
|
||||
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
|
||||
|
||||
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
|
||||
|
||||
### 1.3 Dirty data inventory
|
||||
|
||||
The migration normalizer must survive these real values from production:
|
||||
|
||||
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
|
||||
|
||||
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
|
||||
|
||||
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
|
||||
|
||||
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
|
||||
|
||||
### 1.4 Existing battle-tested algorithm
|
||||
|
||||
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
|
||||
|
||||
### 1.5 Why the website is no longer the source of new dirty data
|
||||
|
||||
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Three artifacts, layered:
|
||||
|
||||
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
|
||||
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
|
||||
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
|
||||
|
||||
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
|
||||
|
||||
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Normalization library
|
||||
|
||||
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
|
||||
|
||||
### 3.1 `normalizeName(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeName(raw: string): {
|
||||
display: string; // human-readable, kept for UI
|
||||
normalized: string; // for matching
|
||||
surnameToken?: string; // for surname-based blocking
|
||||
};
|
||||
```
|
||||
|
||||
- Trim leading/trailing whitespace
|
||||
- Replace `\r`, `\n`, tabs with single space
|
||||
- Collapse consecutive whitespace to single space
|
||||
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
|
||||
- `display` preserves user's intent (slash-with-company stays intact)
|
||||
- `normalized` is `display.toLowerCase()` for comparison
|
||||
- `surnameToken` is the last non-particle token for blocking
|
||||
|
||||
### 3.2 `normalizeEmail(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeEmail(raw: string): string | null;
|
||||
```
|
||||
|
||||
- Trim + lowercase
|
||||
- Validate via `zod.email()` schema
|
||||
- Returns `null` for empty / invalid (caller decides what to do)
|
||||
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
|
||||
|
||||
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
|
||||
|
||||
```ts
|
||||
export function normalizePhone(
|
||||
raw: string,
|
||||
defaultCountry: string,
|
||||
): {
|
||||
e164: string | null; // canonical, e.g. '+15742740548'
|
||||
country: string | null; // ISO-3166-1 alpha-2
|
||||
display: string | null; // user-facing pretty
|
||||
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
|
||||
} | null;
|
||||
```
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
|
||||
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
|
||||
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
|
||||
4. If starts with `00` → replace with `+`
|
||||
5. If starts with `+` → parse as E.164
|
||||
6. Else if `defaultCountry` provided → parse against that country
|
||||
7. Else return null (caller's problem)
|
||||
|
||||
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
|
||||
|
||||
### 3.4 `resolveCountry(text: string)`
|
||||
|
||||
```ts
|
||||
export function resolveCountry(text: string): {
|
||||
iso: string | null; // ISO-3166-1 alpha-2
|
||||
confidence: 'exact' | 'fuzzy' | 'city' | null;
|
||||
};
|
||||
```
|
||||
|
||||
Reuses `src/lib/i18n/countries.ts`. Pipeline:
|
||||
|
||||
1. Lowercase + strip diacritics
|
||||
2. Exact match against country names (any locale we ship)
|
||||
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
|
||||
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
|
||||
|
||||
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dedup algorithm
|
||||
|
||||
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
|
||||
|
||||
### 4.1 Public API
|
||||
|
||||
```ts
|
||||
export interface MatchCandidate {
|
||||
id: string;
|
||||
fullName: string | null;
|
||||
emails: string[]; // already normalized
|
||||
phonesE164: string[]; // already normalized E.164
|
||||
countryIso: string | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
candidate: MatchCandidate;
|
||||
score: number; // 0–100
|
||||
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export function findClientMatches(
|
||||
input: MatchCandidate,
|
||||
pool: MatchCandidate[],
|
||||
thresholds: DedupThresholds,
|
||||
): MatchResult[];
|
||||
```
|
||||
|
||||
### 4.2 Scoring rules (compound)
|
||||
|
||||
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
|
||||
|
||||
| Rule | Score | Notes |
|
||||
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
|
||||
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
|
||||
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
|
||||
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
|
||||
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
|
||||
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
|
||||
| **Negative**: Same email but different country code on phone | −15 | Suggests spouse / coworker / shared inbox |
|
||||
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | −20 | Two distinct people with the same name |
|
||||
|
||||
### 4.3 Confidence tiers (post-compound)
|
||||
|
||||
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
|
||||
- **score 50–89 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
|
||||
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
|
||||
|
||||
### 4.4 Blocking strategy
|
||||
|
||||
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
|
||||
|
||||
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
|
||||
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
|
||||
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
|
||||
|
||||
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 0–5 candidates per query, regardless of N.
|
||||
|
||||
### 4.5 Performance budget
|
||||
|
||||
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
|
||||
|
||||
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k–10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
|
||||
|
||||
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configurable thresholds (admin settings)
|
||||
|
||||
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
|
||||
|
||||
| Key | Default | Effect |
|
||||
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
|
||||
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
|
||||
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
|
||||
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
|
||||
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
|
||||
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
|
||||
|
||||
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
|
||||
|
||||
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Merge service contract
|
||||
|
||||
### 6.1 Data flow
|
||||
|
||||
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
|
||||
|
||||
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
|
||||
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
|
||||
- `interests.clientId`
|
||||
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
|
||||
- `clientAddresses.clientId` — same conflict handling
|
||||
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
|
||||
- `clientTags.clientId`
|
||||
- `clientYachtMembership.clientId` (or whatever the table is called)
|
||||
- `auditLogs.entityId` — annotate, don't move (audit truth)
|
||||
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
|
||||
4. **Soft-archive loser** — `loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
|
||||
5. **Write `clientMergeLog`** — `{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
|
||||
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
|
||||
|
||||
### 6.2 Schema additions (migration)
|
||||
|
||||
`clients` table gets a new column:
|
||||
|
||||
```ts
|
||||
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
|
||||
```
|
||||
|
||||
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
```
|
||||
|
||||
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
|
||||
|
||||
### 6.3 Undo
|
||||
|
||||
`unmergeClients(mergeLogId, ctx)`:
|
||||
|
||||
1. Within the undo window, look up the snapshot
|
||||
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
|
||||
3. Restore loser's contacts/addresses/notes/tags from snapshot
|
||||
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
|
||||
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
|
||||
|
||||
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
|
||||
|
||||
### 6.4 Concurrency
|
||||
|
||||
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime surfaces
|
||||
|
||||
### 7.1 Layer 1 — At-create suggestion
|
||||
|
||||
In `ClientForm` (and the public `register` form once that hits the new system):
|
||||
|
||||
- Debounced 300ms after email or phone field changes
|
||||
- Calls `findClientMatches` against current port's clients
|
||||
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ This looks like an existing client │
|
||||
│ ML Marcus Laurent │
|
||||
│ marcus@… +33 6 12 34 56 78 │
|
||||
│ 2 interests · last 9d ago │
|
||||
│ [ Use this client ] [ Create new ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
|
||||
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
|
||||
|
||||
### 7.2 Layer 2 — Interest-level same-berth guard
|
||||
|
||||
Cheap one-liner in `createInterest` service:
|
||||
|
||||
- Check `(clientId, berthId)` against existing non-archived interests
|
||||
- If hit, throw `BerthDuplicateError` with the existing interest details
|
||||
- UI catches and prompts: "Update existing or create separate?"
|
||||
|
||||
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
|
||||
|
||||
### 7.3 Layer 3 — Background scoring + review queue
|
||||
|
||||
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
|
||||
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
|
||||
```ts
|
||||
export const clientMergeCandidates = pgTable('client_merge_candidates', {
|
||||
id: text('id').primaryKey()...,
|
||||
portId: text('port_id').notNull()...,
|
||||
clientAId: text('client_a_id').notNull()...,
|
||||
clientBId: text('client_b_id').notNull()...,
|
||||
score: integer('score').notNull(),
|
||||
reasons: jsonb('reasons').notNull(),
|
||||
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
||||
createdAt: timestamp('created_at')...,
|
||||
resolvedAt: timestamp('resolved_at'),
|
||||
resolvedBy: text('resolved_by'),
|
||||
})
|
||||
```
|
||||
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
|
||||
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
|
||||
|
||||
---
|
||||
|
||||
## 8. NocoDB → new system field mapping
|
||||
|
||||
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
|
||||
|
||||
### 8.1 Top-level transform
|
||||
|
||||
```
|
||||
NocoDB Interests row
|
||||
─→ 0–1 client (deduped against existing pool)
|
||||
─→ 0–1 client_address
|
||||
─→ 0–2 client_contacts (email, phone)
|
||||
─→ exactly 1 interest
|
||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
||||
─→ 0–1 document (when documensoID present)
|
||||
```
|
||||
|
||||
### 8.2 Field map
|
||||
|
||||
| NocoDB field | Target | Transform |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Full Name` | `clients.fullName` | `normalizeName().display` |
|
||||
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
|
||||
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
|
||||
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
|
||||
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
|
||||
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
|
||||
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
|
||||
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
|
||||
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
|
||||
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
|
||||
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
|
||||
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
|
||||
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
|
||||
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
|
||||
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
|
||||
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
|
||||
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
|
||||
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
|
||||
| `Time LOI Sent` | `interests.dateContractSent` | parse |
|
||||
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
|
||||
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
|
||||
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
|
||||
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
|
||||
|
||||
### 8.3 Sales-stage mapping (8 → 9)
|
||||
|
||||
| NocoDB | New (PIPELINE_STAGES) |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| General Qualified Interest | `open` |
|
||||
| Specific Qualified Interest | `details_sent` |
|
||||
| EOI and NDA Sent | `eoi_sent` |
|
||||
| Signed EOI and NDA | `eoi_signed` |
|
||||
| Made Reservation | `deposit_10pct` |
|
||||
| Contract Negotiation | `contract_sent` |
|
||||
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
|
||||
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
|
||||
|
||||
### 8.4 Other tables
|
||||
|
||||
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
|
||||
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
|
||||
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
|
||||
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
|
||||
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
|
||||
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration script
|
||||
|
||||
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
|
||||
|
||||
```
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
|
||||
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
|
||||
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
|
||||
Reads the apply log, undoes the writes (only valid within the undo window).
|
||||
```
|
||||
|
||||
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
|
||||
|
||||
### 9.1 Dry-run report format
|
||||
|
||||
`.migration/<timestamp>/report.csv`:
|
||||
|
||||
```csv
|
||||
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
|
||||
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
|
||||
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
|
||||
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
|
||||
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
|
||||
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
|
||||
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
|
||||
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
|
||||
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
|
||||
```
|
||||
|
||||
Plus `.migration/<timestamp>/summary.md`:
|
||||
|
||||
```
|
||||
# Migration Dry-Run — 2026-05-03 14:23 UTC
|
||||
|
||||
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
|
||||
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
|
||||
|
||||
Auto-linked (high confidence, no human action needed):
|
||||
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
|
||||
- John Lynch: rows 716,725 → 1 client + 2 interests
|
||||
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
|
||||
- [12 more]
|
||||
|
||||
Flagged for manual review (medium confidence):
|
||||
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
|
||||
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
|
||||
- [4 more]
|
||||
|
||||
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
|
||||
- Row 239: "Sag Harbor Y" → AI (likely US)
|
||||
- [6 more]
|
||||
|
||||
Phone parsing failed for 3 rows. All flagged, no contact created:
|
||||
- Row 178: empty
|
||||
- Row 641: placeholder "+447000000000"
|
||||
- Row 175: empty
|
||||
|
||||
Run `--apply` to commit these changes.
|
||||
```
|
||||
|
||||
### 9.2 Apply phase
|
||||
|
||||
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
|
||||
|
||||
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
|
||||
|
||||
### 9.3 Idempotency
|
||||
|
||||
The script tracks NocoDB row IDs in a `migration_source_links` table:
|
||||
|
||||
```ts
|
||||
export const migrationSourceLinks = pgTable('migration_source_links', {
|
||||
id: text('id').primaryKey()...,
|
||||
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
|
||||
sourceId: text('source_id').notNull(), // NocoDB row id as string
|
||||
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
|
||||
targetEntityId: text('target_entity_id').notNull(),
|
||||
appliedAt: timestamp('applied_at')...,
|
||||
appliedBy: text('applied_by'),
|
||||
}, (table) => [
|
||||
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
|
||||
]);
|
||||
```
|
||||
|
||||
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
### 10.1 Library-level (vitest unit)
|
||||
|
||||
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
|
||||
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
|
||||
|
||||
### 10.2 Service-level (vitest integration)
|
||||
|
||||
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
|
||||
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
|
||||
|
||||
### 10.3 Migration script (vitest integration with NocoDB mock)
|
||||
|
||||
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
|
||||
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
|
||||
|
||||
### 10.4 E2E (Playwright)
|
||||
|
||||
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
|
||||
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollback plan
|
||||
|
||||
Three layers of safety, ordered by reversibility:
|
||||
|
||||
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
|
||||
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
|
||||
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
|
||||
|
||||
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items
|
||||
|
||||
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
|
||||
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
|
||||
- **Profile photo / face match** — out of scope.
|
||||
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
|
||||
|
||||
---
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
|
||||
|
||||
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
|
||||
|
||||
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~5–7 days.
|
||||
|
||||
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
|
||||
|
||||
Total: ~10–12 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.
|
||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||
* contacts where it's null or empty.
|
||||
*
|
||||
* The legacy seed (and pre-normalization production data) stored phone
|
||||
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||
* is what every UI surface and dedup matcher reads. This script runs the
|
||||
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||
* form back.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||
*
|
||||
* The dry-run report prints, for each unparseable row, the contact id +
|
||||
* raw value so you can hand-clean before re-running.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema/clients';
|
||||
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface PhoneRow {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string | null;
|
||||
valueCountry: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||
console.log('');
|
||||
|
||||
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||
// `value_e164` null/empty.
|
||||
const rows: PhoneRow[] = await db
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueCountry: clientContacts.valueCountry,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(` found ${rows.length} candidate rows`);
|
||||
|
||||
let parsedFull = 0;
|
||||
let parsedE164Only = 0;
|
||||
let unparseable = 0;
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
valueE164: string;
|
||||
valueCountry: CountryCode | null;
|
||||
}> = [];
|
||||
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value) continue;
|
||||
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||
|
||||
if (parsed1.e164 && parsed1.country) {
|
||||
// Both e164 + country resolved — best case.
|
||||
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||
parsedFull++;
|
||||
} else if (parsed1.e164) {
|
||||
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||
// fictional/reserved range — libphonenumber returns the e164 form
|
||||
// but refuses to assign a country). Still safe to write — the e164
|
||||
// is canonical. Country stays null.
|
||||
updates.push({
|
||||
id: row.id,
|
||||
valueE164: parsed1.e164,
|
||||
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||
});
|
||||
parsedE164Only++;
|
||||
} else {
|
||||
fails.push({
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
reason: row.value.trim().startsWith('+')
|
||||
? 'has + prefix but parse failed'
|
||||
: 'no leading + and no country hint',
|
||||
});
|
||||
unparseable++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||
console.log(' ✗ unparseable ', unparseable);
|
||||
console.log('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
console.log('Failures (first 10):');
|
||||
for (const f of fails.slice(0, 10)) {
|
||||
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No updates to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Writing ${updates.length} updates...`);
|
||||
|
||||
for (const u of updates) {
|
||||
await db
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
valueE164: u.valueE164,
|
||||
valueCountry: u.valueCountry,
|
||||
})
|
||||
.where(eq(clientContacts.id, u.id));
|
||||
}
|
||||
|
||||
console.log(` ✓ wrote ${updates.length} rows`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
126
scripts/load-berths-to-port-nimara.ts
Normal file
126
scripts/load-berths-to-port-nimara.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* One-shot: load the 117-berth NocoDB snapshot into the port-nimara
|
||||
* port, skipping any moorings that already exist.
|
||||
*
|
||||
* The original seed only seeded 12 hand-rolled berths into port-nimara
|
||||
* (A-01..D-03), but the migration's interest rows reference moorings
|
||||
* across A-01..E-18. This loads the full set so interest→berth links
|
||||
* resolve cleanly on the next migration run.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, sql, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import berthSnapshot from '@/lib/db/seed-data/berths.json';
|
||||
|
||||
interface SnapshotBerth {
|
||||
mooringNumber: string;
|
||||
area: string;
|
||||
status: 'available' | 'under_offer' | 'sold';
|
||||
lengthFt: number | null;
|
||||
widthFt: number | null;
|
||||
draftFt: number | null;
|
||||
lengthM: number | null;
|
||||
widthM: number | null;
|
||||
draftM: number | null;
|
||||
widthIsMinimum: boolean;
|
||||
nominalBoatSize: number | null;
|
||||
nominalBoatSizeM: number | null;
|
||||
waterDepth: number | null;
|
||||
waterDepthM: number | null;
|
||||
waterDepthIsMinimum: boolean;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: number | null;
|
||||
voltage: number | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
price: number | null;
|
||||
bowFacing: string | null;
|
||||
berthApproved: boolean;
|
||||
statusOverrideMode: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, 'port-nimara'))
|
||||
.limit(1);
|
||||
if (!port) throw new Error('port-nimara not found');
|
||||
|
||||
const snapshot = berthSnapshot as unknown as SnapshotBerth[];
|
||||
|
||||
// Existing moorings — skip these.
|
||||
const existingRows = await db
|
||||
.select({ mooringNumber: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
const existingMoorings = new Set(existingRows.map((r) => r.mooringNumber));
|
||||
|
||||
const toInsert = snapshot.filter((b) => !existingMoorings.has(b.mooringNumber));
|
||||
console.log(
|
||||
`Snapshot: ${snapshot.length} berths, existing in port-nimara: ${existingRows.length}, to insert: ${toInsert.length}`,
|
||||
);
|
||||
|
||||
if (toInsert.length === 0) {
|
||||
console.log('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(berths)
|
||||
.values(
|
||||
toInsert.map((b) => ({
|
||||
portId: port.id,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
|
||||
widthFt: b.widthFt != null ? String(b.widthFt) : null,
|
||||
draftFt: b.draftFt != null ? String(b.draftFt) : null,
|
||||
lengthM: b.lengthM != null ? String(b.lengthM) : null,
|
||||
widthM: b.widthM != null ? String(b.widthM) : null,
|
||||
draftM: b.draftM != null ? String(b.draftM) : null,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
|
||||
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
|
||||
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
|
||||
voltage: b.voltage != null ? String(b.voltage) : null,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.price != null ? String(b.price) : null,
|
||||
priceCurrency: 'USD',
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
tenureType: 'permanent' as const,
|
||||
})),
|
||||
)
|
||||
.returning({ id: berths.id, mooringNumber: berths.mooringNumber });
|
||||
|
||||
console.log(`Inserted ${inserted.length} berths.`);
|
||||
|
||||
// Suppress unused-import warning if eslint is strict.
|
||||
void and;
|
||||
void sql;
|
||||
void inArray;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,21 +7,30 @@
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||
* Same, but tags the planned writes with the named port (matters for
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
* [Not yet implemented — apply phase comes in a follow-up PR.]
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||
* Re-fetches NocoDB, re-transforms, then writes the planned rows
|
||||
* into the target port via the idempotent `migration_source_links`
|
||||
* ledger. Re-runs are safe — already-imported source IDs are skipped.
|
||||
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
|
||||
* `--unsafe-skip-redirect-check` is also passed.
|
||||
*
|
||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
@@ -31,6 +40,7 @@ interface CliArgs {
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
unsafeSkipRedirectCheck: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -39,6 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
unsafeSkipRedirectCheck: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
@@ -46,6 +57,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -64,20 +76,50 @@ function printHelp(): void {
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
Apply phase. (Not yet implemented.)
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||
Re-fetches NocoDB, re-transforms, writes via migration_source_links
|
||||
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
|
||||
(unless --unsafe-skip-redirect-check is also passed).
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write to the new DB. (Not yet supported.)
|
||||
--port-slug <slug> Port slug to attach to all imported entities.
|
||||
Defaults to the first available port if omitted.
|
||||
--report <dir> Path to a previously-generated report dir
|
||||
(only used by --apply).
|
||||
-h, --help Show this help.
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write rows to the DB.
|
||||
--port-slug <slug> Port slug to attach to all imported
|
||||
entities. Defaults to the first
|
||||
available port if omitted.
|
||||
--report <dir> Path to a previously-generated report
|
||||
dir (only used by --apply).
|
||||
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
|
||||
check. Only use in production cutover.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target port: use the slug if provided, otherwise the first
|
||||
* port found. Errors out cleanly if the slug doesn't match any port.
|
||||
*/
|
||||
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
|
||||
if (slug) {
|
||||
const [p] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slug))
|
||||
.limit(1);
|
||||
if (!p) {
|
||||
console.error(`No port found with slug "${slug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: p.id, slug: p.slug };
|
||||
}
|
||||
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
|
||||
if (!first) {
|
||||
console.error('No ports exist in the target DB. Seed at least one port before applying.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: first.id, slug: first.slug };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -87,13 +129,21 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.apply) {
|
||||
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.');
|
||||
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.');
|
||||
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||
// operator explicitly opts out (production cutover).
|
||||
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
|
||||
console.error(
|
||||
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
|
||||
);
|
||||
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
|
||||
console.error(
|
||||
'If you are running the production cutover and have read that doc, add ' +
|
||||
'--unsafe-skip-redirect-check to override.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── Dry-run path ───────────────────────────────────────────────────────────
|
||||
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
@@ -110,8 +160,7 @@ async function main(): Promise<void> {
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root (the script itself
|
||||
// lives in scripts/; we want the .migration dir at the repo root).
|
||||
// Resolve output paths relative to the worktree root.
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
@@ -120,7 +169,7 @@ async function main(): Promise<void> {
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Console summary ──────────────────────────────────────────────────────
|
||||
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
console.log('=== Migration Plan Summary ===');
|
||||
@@ -135,6 +184,50 @@ async function main(): Promise<void> {
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
console.log(` Full report: ${paths.summaryPath}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('');
|
||||
console.log('Dry-run complete. Re-run with --apply to write rows.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Apply path ───────────────────────────────────────────────────────────
|
||||
|
||||
const port = await resolvePort(args.portSlug);
|
||||
const applyId = randomUUID();
|
||||
|
||||
console.log('');
|
||||
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
|
||||
console.log(`[migrate] Apply id: ${applyId}`);
|
||||
console.log('[migrate] Inserting…');
|
||||
|
||||
const applyStart = Date.now();
|
||||
const result = await applyPlan(plan, { port, applyId });
|
||||
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||
|
||||
console.log('');
|
||||
console.log('=== Apply Result ===');
|
||||
console.log(` Time: ${applyElapsed}s`);
|
||||
console.log(
|
||||
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Contacts: ${result.contactsInserted} inserted`);
|
||||
console.log(` Addresses: ${result.addressesInserted} inserted`);
|
||||
console.log(` Yachts: ${result.yachtsInserted} inserted`);
|
||||
console.log(
|
||||
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||
);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('');
|
||||
console.log('Warnings:');
|
||||
for (const w of result.warnings.slice(0, 20)) {
|
||||
console.log(` - ${w}`);
|
||||
}
|
||||
if (result.warnings.length > 20) {
|
||||
console.log(` … ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
|
||||
108
scripts/smoke-test-redirect.ts
Normal file
108
scripts/smoke-test-redirect.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Live smoke test for EMAIL_REDIRECT_TO.
|
||||
*
|
||||
* Actually calls `sendEmail()` (the centralized helper used by every
|
||||
* outbound email path in the app) with a fake real-client address. The
|
||||
* SMTP transporter is monkey-patched to capture the message instead of
|
||||
* actually delivering it, so this is safe to run anywhere.
|
||||
*
|
||||
* Prints the captured `to` + `subject` so the operator can see with their
|
||||
* own eyes that the redirect happened. Exits non-zero if the redirect
|
||||
* failed for any reason.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/smoke-test-redirect.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
async function main() {
|
||||
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
if (!expectedRedirect) {
|
||||
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
|
||||
console.log('');
|
||||
|
||||
// Monkey-patch nodemailer's createTransport so we capture the call
|
||||
// without actually delivering. This is the same pattern the unit
|
||||
// tests use, but at the live import-time level so we're testing the
|
||||
// exact code path that runs in production.
|
||||
const nodemailer = await import('nodemailer');
|
||||
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
|
||||
const originalCreateTransport = nodemailer.default.createTransport;
|
||||
// @ts-expect-error monkey-patch
|
||||
nodemailer.default.createTransport = () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMail: async (msg: any) => {
|
||||
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
|
||||
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
|
||||
},
|
||||
});
|
||||
|
||||
// Now import sendEmail (gets the patched transporter).
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
|
||||
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
|
||||
const realSubject = 'Important: Your contract is ready';
|
||||
|
||||
console.log('[smoke] calling sendEmail(...) with:');
|
||||
console.log(` to: ${realClientEmail}`);
|
||||
console.log(` subject: "${realSubject}"`);
|
||||
console.log('');
|
||||
|
||||
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
|
||||
|
||||
// Restore the original transport (be a good citizen).
|
||||
// @ts-expect-error monkey-patch
|
||||
nodemailer.default.createTransport = originalCreateTransport;
|
||||
|
||||
console.log('[smoke] captured outbound message:');
|
||||
console.log(` to: ${captured[0]?.to}`);
|
||||
console.log(` subject: "${captured[0]?.subject}"`);
|
||||
console.log(` from: ${captured[0]?.from}`);
|
||||
console.log('');
|
||||
|
||||
// Assertions
|
||||
let pass = true;
|
||||
|
||||
if (captured.length !== 1) {
|
||||
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (captured[0]?.to !== expectedRedirect) {
|
||||
console.error(
|
||||
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof captured[0]?.subject !== 'string' ||
|
||||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
|
||||
) {
|
||||
console.error(
|
||||
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
|
||||
console.log(
|
||||
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('');
|
||||
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Tag,
|
||||
Upload,
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -29,132 +30,186 @@ interface AdminSection {
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSection[] = [
|
||||
interface AdminGroup {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: AdminSection[];
|
||||
}
|
||||
|
||||
const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
href: 'users',
|
||||
label: 'Users',
|
||||
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
||||
icon: Users,
|
||||
title: 'Access',
|
||||
description: 'Who can sign in and what they can do once they do.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
label: 'Invitations',
|
||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||
icon: Mail,
|
||||
title: 'Configuration',
|
||||
description: 'Branding, integrations, and per-port settings.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
label: 'Roles & Permissions',
|
||||
description: 'Default permission sets and per-port role overrides.',
|
||||
icon: Shield,
|
||||
title: 'Content',
|
||||
description: 'Forms, templates, and labels that users see.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
label: 'Audit Log',
|
||||
description: 'Searchable log of every authenticated mutation in the system.',
|
||||
icon: ScrollText,
|
||||
title: 'Data Quality',
|
||||
description: 'Cleanup, imports, and the audit trail.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
label: 'Email Settings',
|
||||
description: 'From address, signatures, and per-port SMTP overrides.',
|
||||
icon: Mail,
|
||||
title: 'Operations',
|
||||
description: 'Health checks and disaster recovery.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
label: 'Documenso & EOI',
|
||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||
icon: FileText,
|
||||
title: 'Tenancy',
|
||||
description: 'Multi-port and multi-install scaffolding.',
|
||||
sections: [
|
||||
{
|
||||
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',
|
||||
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: '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,
|
||||
title: 'Integrations',
|
||||
description: 'Third-party providers wired into the app.',
|
||||
sections: [
|
||||
{
|
||||
href: 'ocr',
|
||||
label: 'Receipt OCR',
|
||||
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -165,36 +220,46 @@ export default async function AdminLandingPage({
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
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">
|
||||
{SECTIONS.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{GROUPS.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{group.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.sections.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||
import { Toaster } from 'sonner';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
@@ -66,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -241,7 +241,14 @@ export function SettingsManager() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div
|
||||
key={setting.key}
|
||||
// Stack label/description above the input on phone widths.
|
||||
// The previous flex row crushed the label column into a
|
||||
// narrow vertical stripe ("Inquiry / Contact / Email" wrapping
|
||||
// one word per line) while the input took the rest.
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
@@ -249,7 +256,7 @@ export function SettingsManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="w-64"
|
||||
className="w-full sm:w-64"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
@@ -283,7 +290,10 @@ export function SettingsManager() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div
|
||||
key={setting.key}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
@@ -291,7 +301,7 @@ export function SettingsManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
className="w-full sm:w-24"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
|
||||
@@ -22,7 +22,11 @@ export function AlertRail() {
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
className="flex h-full flex-col gap-3"
|
||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
||||
// gives this rail a sibling column whose height it should match. On
|
||||
// mobile (single-column stack) there's no fixed-height context, so
|
||||
// forcing 100% height makes the section overflow / look stretched.
|
||||
className="flex flex-col gap-3 xl:h-full"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
|
||||
@@ -44,6 +44,17 @@ type BerthDetailData = {
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
sidePontoon: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
bowFacing: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
@@ -167,7 +178,10 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<DetailHeaderStrip>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Stacks vertically on phone widths so the action buttons don't
|
||||
squeeze the area subtitle into a two-line wrap. From sm up the
|
||||
title/area block sits side-by-side with the action buttons. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground">
|
||||
@@ -182,7 +196,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
|
||||
<PermissionGate resource="berths" action="edit">
|
||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
|
||||
@@ -16,18 +16,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
||||
import {
|
||||
BERTH_AREAS,
|
||||
BERTH_SIDE_PONTOON_OPTIONS,
|
||||
BERTH_MOORING_TYPES,
|
||||
BERTH_CLEAT_TYPES,
|
||||
BERTH_CLEAT_CAPACITIES,
|
||||
BERTH_BOLLARD_TYPES,
|
||||
BERTH_BOLLARD_CAPACITIES,
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
} from '@/lib/constants';
|
||||
|
||||
interface BerthFormProps {
|
||||
berth: {
|
||||
@@ -42,16 +46,27 @@ interface BerthFormProps {
|
||||
draftFt: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
bowFacing: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
mooringType: string | null;
|
||||
access: string | null;
|
||||
berthApproved: boolean | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
@@ -59,10 +74,42 @@ interface BerthFormProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Optional select that allows clearing back to "no value". */
|
||||
function SelectOrEmpty({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChange: (next: string | undefined) => void;
|
||||
options: readonly string[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const NONE = '__none';
|
||||
return (
|
||||
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
|
||||
|
||||
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
resolver: zodResolver(updateBerthSchema),
|
||||
defaultValues: {
|
||||
area: berth.area ?? undefined,
|
||||
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
|
||||
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
|
||||
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
|
||||
widthM: berth.widthM ? Number(berth.widthM) : undefined,
|
||||
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
|
||||
draftM: berth.draftM ? Number(berth.draftM) : undefined,
|
||||
lengthFt: numOrUndef(berth.lengthFt),
|
||||
lengthM: numOrUndef(berth.lengthM),
|
||||
widthFt: numOrUndef(berth.widthFt),
|
||||
widthM: numOrUndef(berth.widthM),
|
||||
draftFt: numOrUndef(berth.draftFt),
|
||||
draftM: numOrUndef(berth.draftM),
|
||||
widthIsMinimum: berth.widthIsMinimum ?? false,
|
||||
price: berth.price ? Number(berth.price) : undefined,
|
||||
nominalBoatSize: numOrUndef(berth.nominalBoatSize),
|
||||
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
|
||||
waterDepth: numOrUndef(berth.waterDepth),
|
||||
waterDepthM: numOrUndef(berth.waterDepthM),
|
||||
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
|
||||
sidePontoon: berth.sidePontoon ?? undefined,
|
||||
powerCapacity: numOrUndef(berth.powerCapacity),
|
||||
voltage: numOrUndef(berth.voltage),
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
cleatType: berth.cleatType ?? undefined,
|
||||
cleatCapacity: berth.cleatCapacity ?? undefined,
|
||||
bollardType: berth.bollardType ?? undefined,
|
||||
bollardCapacity: berth.bollardCapacity ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
bowFacing: berth.bowFacing ?? undefined,
|
||||
price: numOrUndef(berth.price),
|
||||
priceCurrency: berth.priceCurrency,
|
||||
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
|
||||
tenureYears: berth.tenureYears ?? undefined,
|
||||
tenureStartDate: berth.tenureStartDate ?? undefined,
|
||||
tenureEndDate: berth.tenureEndDate ?? undefined,
|
||||
powerCapacity: berth.powerCapacity ?? undefined,
|
||||
voltage: berth.voltage ?? undefined,
|
||||
mooringType: berth.mooringType ?? undefined,
|
||||
access: berth.access ?? undefined,
|
||||
berthApproved: berth.berthApproved ?? false,
|
||||
},
|
||||
});
|
||||
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
}
|
||||
|
||||
const tenureType = watch('tenureType');
|
||||
const area = watch('area');
|
||||
const sidePontoon = watch('sidePontoon');
|
||||
const mooringType = watch('mooringType');
|
||||
const cleatType = watch('cleatType');
|
||||
const cleatCapacity = watch('cleatCapacity');
|
||||
const bollardType = watch('bollardType');
|
||||
const bollardCapacity = watch('bollardCapacity');
|
||||
const access = watch('access');
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Area</Label>
|
||||
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
|
||||
<Label>Area</Label>
|
||||
<SelectOrEmpty
|
||||
value={area}
|
||||
onChange={(v) => setValue('area', v)}
|
||||
options={BERTH_AREAS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mooringType">Mooring Type</Label>
|
||||
<Input id="mooringType" {...register('mooringType')} />
|
||||
<Label htmlFor="bowFacing">Bow Facing</Label>
|
||||
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access">Access</Label>
|
||||
<Input id="access" {...register('access')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="berthApproved"
|
||||
@@ -168,41 +234,159 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Length (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthFt')} />
|
||||
<Input type="number" step="0.01" {...register('lengthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Length (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('lengthM')} />
|
||||
<Input type="number" step="0.01" {...register('lengthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthFt')} />
|
||||
<Input type="number" step="0.01" {...register('widthFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Width (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('widthM')} />
|
||||
<Input type="number" step="0.01" {...register('widthM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (ft)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftFt')} />
|
||||
<Input type="number" step="0.01" {...register('draftFt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Draft (m)</Label>
|
||||
<Input type="number" step="0.1" {...register('draftM')} />
|
||||
<Input type="number" step="0.01" {...register('draftM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nominal Boat Size (ft)</Label>
|
||||
<Input type="number" step="1" {...register('nominalBoatSize')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nominal Boat Size (m)</Label>
|
||||
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Water Depth (ft)</Label>
|
||||
<Input type="number" step="0.01" {...register('waterDepth')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Water Depth (m)</Label>
|
||||
<Input type="number" step="0.01" {...register('waterDepthM')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
checked={watch('widthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="widthIsMinimum"
|
||||
checked={watch('widthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="waterDepthIsMinimum"
|
||||
checked={watch('waterDepthIsMinimum') ?? false}
|
||||
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
|
||||
/>
|
||||
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Mooring & Hardware */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Mooring & Hardware
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>Side Pontoon</Label>
|
||||
<SelectOrEmpty
|
||||
value={sidePontoon}
|
||||
onChange={(v) => setValue('sidePontoon', v)}
|
||||
options={BERTH_SIDE_PONTOON_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Mooring Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={mooringType}
|
||||
onChange={(v) => setValue('mooringType', v)}
|
||||
options={BERTH_MOORING_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Cleat Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={cleatType}
|
||||
onChange={(v) => setValue('cleatType', v)}
|
||||
options={BERTH_CLEAT_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleat Capacity</Label>
|
||||
<SelectOrEmpty
|
||||
value={cleatCapacity}
|
||||
onChange={(v) => setValue('cleatCapacity', v)}
|
||||
options={BERTH_CLEAT_CAPACITIES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bollard Type</Label>
|
||||
<SelectOrEmpty
|
||||
value={bollardType}
|
||||
onChange={(v) => setValue('bollardType', v)}
|
||||
options={BERTH_BOLLARD_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bollard Capacity</Label>
|
||||
<SelectOrEmpty
|
||||
value={bollardCapacity}
|
||||
onChange={(v) => setValue('bollardCapacity', v)}
|
||||
options={BERTH_BOLLARD_CAPACITIES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Power */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Power
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity (kW)</Label>
|
||||
<Input type="number" step="1" {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage (V at 60Hz)</Label>
|
||||
<Input type="number" step="1" {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Access */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Access
|
||||
</h3>
|
||||
<SelectOrEmpty
|
||||
value={access}
|
||||
onChange={(v) => setValue('access', v)}
|
||||
options={BERTH_ACCESS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Price */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Infrastructure
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Power Capacity</Label>
|
||||
<Input {...register('powerCapacity')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Voltage</Label>
|
||||
<Input {...register('voltage')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
@@ -48,22 +48,57 @@ type BerthData = {
|
||||
|
||||
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (!value && value !== 0 && value !== false) return null;
|
||||
// Mobile-first: stack vertically with label on top so long values
|
||||
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
|
||||
// From `sm` (>=640px) up: switch to the original two-column layout.
|
||||
return (
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right max-w-[60%]">{value}</span>
|
||||
<span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
|
||||
const fmt = (v: string | null, fractionDigits = 2): string | null => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
return n.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDim = (ft: string | null, m: string | null) => {
|
||||
const parts = [];
|
||||
if (ft) parts.push(`${ft} ft`);
|
||||
if (m) parts.push(`${m} m`);
|
||||
const ftFmt = fmt(ft);
|
||||
const mFmt = fmt(m);
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
|
||||
const ftFmt = fmt(ft, 0);
|
||||
const mFmt = fmt(m);
|
||||
const parts: string[] = [];
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const formatPower = (kw: string | null) => {
|
||||
const v = fmt(kw, 0);
|
||||
return v ? `${v} kW` : null;
|
||||
};
|
||||
|
||||
const formatVoltage = (v: string | null) => {
|
||||
const fv = fmt(v, 0);
|
||||
return fv ? `${fv} V` : null;
|
||||
};
|
||||
|
||||
const price = berth.price
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -97,7 +132,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||
<SpecRow
|
||||
label="Nominal Boat Size"
|
||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Water Depth"
|
||||
@@ -122,8 +157,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
||||
<SpecRow label="Voltage" value={berth.voltage} />
|
||||
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
|
||||
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
|
||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ClientRow {
|
||||
createdAt: string;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
interestCount?: number;
|
||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||
import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -12,31 +13,28 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
|
||||
interface ClientDetailHeaderProps {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
nationalityIso?: string | null;
|
||||
archivedAt?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||
createdAt?: string;
|
||||
contacts?: Array<{
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
isPrimary: boolean;
|
||||
label?: string | null;
|
||||
}>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
clientPortalEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
@@ -62,19 +60,34 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
});
|
||||
|
||||
const primaryEmail =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'email');
|
||||
const primaryPhone =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||
const primaryPhoneContact =
|
||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'phone');
|
||||
const primaryPhone = primaryPhoneContact?.value;
|
||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
||||
// display value as a best-effort fallback.
|
||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
||||
: primaryPhoneContact?.value
|
||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const addedLabel = client.createdAt
|
||||
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
|
||||
: null;
|
||||
const meta = [country, addedLabel].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailHeaderStrip>
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
|
||||
{client.fullName}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
@@ -84,31 +97,71 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||
{client.source && (
|
||||
<span>
|
||||
Source:{' '}
|
||||
<span className="text-foreground">
|
||||
{SOURCE_LABELS[client.source] ?? client.source}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{primaryEmail && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{primaryEmail.value}
|
||||
</span>
|
||||
)}
|
||||
{primaryPhone && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{primaryPhone.value}
|
||||
</span>
|
||||
)}
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{primaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{primaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled !== false ? (
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
defaultEmail={primaryEmail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hidden sm:inline-flex">
|
||||
<GdprExportButton clientId={client.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{client.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
@@ -116,34 +169,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isArchived && client.clientPortalEnabled !== false && (
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
defaultEmail={primaryEmail?.value}
|
||||
/>
|
||||
{/* Top-right: archive/restore as a small icon button — destructive
|
||||
action sits out of the primary action flow. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
||||
)}
|
||||
<GdprExportButton clientId={client.id} />
|
||||
<Button
|
||||
variant={isArchived ? 'outline' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</DetailHeaderStrip>
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ interface ClientData {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
valueCountry: string | null;
|
||||
label: string | null;
|
||||
isPrimary: boolean;
|
||||
notes: string | null;
|
||||
|
||||
@@ -366,10 +366,6 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preferred Language</Label>
|
||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneCombobox
|
||||
|
||||
460
src/components/clients/client-interests-tab.tsx
Normal file
460
src/components/clients/client-interests-tab.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
||||
import {
|
||||
StageStepper,
|
||||
useClientInterests,
|
||||
type ClientInterestRow,
|
||||
} from '@/components/clients/client-pipeline-summary';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
|
||||
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General interest',
|
||||
specific_qualified: 'Specific qualified',
|
||||
hot_lead: 'Hot lead',
|
||||
};
|
||||
|
||||
function InterestRowItem({
|
||||
interest,
|
||||
onOpen,
|
||||
}: {
|
||||
interest: ClientInterestRow;
|
||||
onOpen: (i: ClientInterestRow) => void;
|
||||
}) {
|
||||
const stage = safeStage(interest.pipelineStage);
|
||||
|
||||
const berthLabel = interest.berthMooringNumber
|
||||
? `Berth ${interest.berthMooringNumber}`
|
||||
: 'General interest';
|
||||
|
||||
const yachtLabel = interest.yachtName ?? null;
|
||||
|
||||
return (
|
||||
// Tap opens a bottom-sheet preview drawer rather than navigating to the
|
||||
// full interest page. The drawer covers ~80% of mobile interactions
|
||||
// ("what stage is this at, when did we last touch it"). For deeper
|
||||
// edits the drawer has an "Open full page" CTA.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(interest)}
|
||||
className={cn(
|
||||
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
|
||||
'hover:border-border/70 hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{berthLabel}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
</div>
|
||||
{yachtLabel ? (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<StageStepper current={stage} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function lastActivityFor(interest: ClientInterestRow): string | null {
|
||||
const candidates = [interest.dateLastContact, interest.updatedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime())
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
if (candidates.length === 0) return null;
|
||||
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
|
||||
}
|
||||
|
||||
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
|
||||
* the drawer actually reads are typed here; the API returns more. */
|
||||
interface InterestDetail {
|
||||
id: string;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
}
|
||||
|
||||
function useInterestDetail(id: string | null) {
|
||||
return useQuery<{ data: InterestDetail }>({
|
||||
queryKey: ['interest-detail-drawer', id],
|
||||
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
|
||||
enabled: id !== null,
|
||||
// Detail rarely changes during a single drawer-open session; stale-time
|
||||
// keeps re-opens snappy without preventing background refetch.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
|
||||
* empty input so callers can render an "empty" state. */
|
||||
function formatDate(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
/** A single milestone row inside the drawer's milestone summary. Filled
|
||||
* circle when the step is done, hollow when pending. Trailing meta line
|
||||
* shows the date stamp or a "pending" hint. */
|
||||
function MilestoneRow({
|
||||
label,
|
||||
done,
|
||||
date,
|
||||
hint = 'pending',
|
||||
}: {
|
||||
label: string;
|
||||
done: boolean;
|
||||
date: string | null;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-2 py-1">
|
||||
{done ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
|
||||
)}
|
||||
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-sheet preview of a single interest. Designed for the mobile
|
||||
* "tap an interest → see what's happening without leaving the client
|
||||
* page" flow. Shows the pipeline progress, a compact milestone summary
|
||||
* (EOI / Deposit / Contract), lead context, last contact, and a notes
|
||||
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
|
||||
* away via "Open full page →".
|
||||
*/
|
||||
function InterestPreviewDrawer({
|
||||
interest,
|
||||
portSlug,
|
||||
onClose,
|
||||
}: {
|
||||
interest: ClientInterestRow | null;
|
||||
portSlug: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Pin the most recently selected interest so the drawer stays populated
|
||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||
// after `open=false`). Conditional setState is safe here — the guard
|
||||
// ensures it only fires when the prop actually changes to a new row.
|
||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||
if (interest && interest !== pinned) setPinned(interest);
|
||||
const showing = pinned;
|
||||
|
||||
const detail = useInterestDetail(showing?.id ?? null);
|
||||
const fullDetail = detail.data?.data ?? null;
|
||||
|
||||
const open = interest !== null;
|
||||
const stage = showing ? safeStage(showing.pipelineStage) : null;
|
||||
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
|
||||
const reached = (target: PipelineStage) =>
|
||||
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
||||
|
||||
const berthLabel = showing
|
||||
? showing.berthMooringNumber
|
||||
? `Berth ${showing.berthMooringNumber}`
|
||||
: 'General interest'
|
||||
: '';
|
||||
const yachtLabel = showing?.yachtName ?? null;
|
||||
const activity = showing ? lastActivityFor(showing) : null;
|
||||
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
|
||||
|
||||
const leadLabel = fullDetail?.leadCategory
|
||||
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
|
||||
: null;
|
||||
const sourceLabel = fullDetail?.source
|
||||
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
: null;
|
||||
const lastContactDate = formatDate(fullDetail?.dateLastContact);
|
||||
const notesPreview = fullDetail?.notes?.trim() || null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
|
||||
{yachtLabel ? (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{stage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
||||
row card, so the at-a-glance progress hint is consistent
|
||||
across surfaces. */}
|
||||
{stage ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pipeline progress
|
||||
</p>
|
||||
<StageStepper current={stage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Milestones — three sections matching the full interest detail
|
||||
page (EOI / Deposit / Contract). Done-state is derived from
|
||||
the pipeline stage so seed data without per-step dates still
|
||||
renders correctly. The full milestone columns + per-step
|
||||
actions live behind "Open full page". */}
|
||||
<section>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Milestones
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">EOI</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="EOI sent"
|
||||
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
|
||||
date={formatDate(fullDetail?.dateEoiSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="EOI signed"
|
||||
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
|
||||
date={formatDate(fullDetail?.dateEoiSigned)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">Deposit</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Deposit received"
|
||||
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
|
||||
date={formatDate(fullDetail?.dateDepositReceived)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">Contract</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Contract sent"
|
||||
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
|
||||
date={formatDate(fullDetail?.dateContractSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="Contract signed"
|
||||
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
|
||||
date={formatDate(fullDetail?.dateContractSigned)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Compact key/value pairs — lead category, source, last contact,
|
||||
activity. Each row collapses cleanly when its value is
|
||||
missing so the drawer scales from sparse seed data to full
|
||||
records without empty placeholders. */}
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
{leadLabel ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Lead</dt>
|
||||
<dd className="text-right font-medium">{leadLabel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Source</dt>
|
||||
<dd className="text-right font-medium">{sourceLabel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{lastContactDate ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Last contact</dt>
|
||||
<dd className="text-right font-medium">{lastContactDate}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{activity ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Last activity</dt>
|
||||
<dd className="text-right font-medium">{activity}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{notesPreview ? (
|
||||
<section>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Notes
|
||||
</p>
|
||||
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
|
||||
{notesPreview}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Button asChild className="w-full" size="lg">
|
||||
<Link href={fullHref}>
|
||||
Open full page
|
||||
<ArrowRight className="ml-1.5 size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function InterestSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
<Skeleton className="mt-3 h-2 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientInterestsTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useClientInterests(clientId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<InterestSkeleton />
|
||||
<InterestSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
|
||||
}
|
||||
|
||||
const interests = data?.data ?? [];
|
||||
|
||||
if (interests.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<EmptyState
|
||||
title="No interests yet"
|
||||
description="When this client expresses interest in a berth, the sales process will appear here."
|
||||
action={{
|
||||
label: 'Add interest',
|
||||
onClick: () => setCreateOpen(true),
|
||||
}}
|
||||
/>
|
||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const active = interests.filter((i) => !i.archivedAt);
|
||||
const archived = interests.filter((i) => i.archivedAt);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
Add interest
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{active.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{active.map((i) => (
|
||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{archived.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Archived
|
||||
</h4>
|
||||
<div className="space-y-3 opacity-60">
|
||||
{archived.map((i) => (
|
||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<InterestPreviewDrawer
|
||||
interest={previewInterest}
|
||||
portSlug={portSlug}
|
||||
onClose={() => setPreviewInterest(null)}
|
||||
/>
|
||||
|
||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||
@@ -114,6 +116,8 @@ interface ClientTabsOptions {
|
||||
tenureType: string;
|
||||
status: string;
|
||||
}>;
|
||||
interestCount?: number;
|
||||
noteCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
@@ -131,82 +135,82 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<EditableRow label="Full Name">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Nationality">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
await mutation.mutateAsync({ nationalityIso: iso });
|
||||
}}
|
||||
data-testid="client-nationality-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Language">
|
||||
<InlineEditableField
|
||||
value={client.preferredLanguage}
|
||||
onSave={save('preferredLanguage')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
data-testid="client-timezone-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_METHOD_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<EditableRow label="Full Name">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Nationality">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
await mutation.mutateAsync({ nationalityIso: iso });
|
||||
}}
|
||||
data-testid="client-nationality-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
data-testid="client-timezone-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_METHOD_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source Details">
|
||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
{/* Source */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source Details">
|
||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -219,6 +223,12 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
label: 'Overview',
|
||||
content: <OverviewTab clientId={clientId} client={client} />,
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
badge: client.interestCount,
|
||||
content: <ClientInterestsTab clientId={clientId} />,
|
||||
},
|
||||
{
|
||||
id: 'yachts',
|
||||
label: 'Yachts',
|
||||
@@ -251,18 +261,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Interests will appear here once created.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
badge: client.noteCount,
|
||||
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -155,6 +155,7 @@ function ContactRow({
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
||||
const [phoneEditing, setPhoneEditing] = useState(false);
|
||||
|
||||
async function togglePrimary() {
|
||||
try {
|
||||
@@ -174,17 +175,31 @@ function ContactRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
{/* Left: channel + value */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
data-editing={phoneEditing ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group rounded-lg border text-sm transition-all duration-150',
|
||||
// Active-edit dilation: lift the row out of the muted baseline with a
|
||||
// soft primary ring + slightly brighter surface. Single visual signal
|
||||
// replaces the need for any "now editing" label.
|
||||
phoneEditing
|
||||
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
|
||||
: 'bg-muted/30 p-2 gap-2',
|
||||
// Stack value editor / action cluster on mobile; single row on sm+.
|
||||
'flex flex-col sm:flex-row sm:items-center',
|
||||
)}
|
||||
>
|
||||
{/* Top / left: channel + value */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</ChannelPicker>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||
<InlinePhoneField
|
||||
e164={contact.valueE164 ?? null}
|
||||
country={contact.valueCountry ?? null}
|
||||
onEditingChange={setPhoneEditing}
|
||||
onSave={async ({ e164, country }) => {
|
||||
if (!e164) {
|
||||
toast.error('Phone number is required');
|
||||
@@ -208,42 +223,60 @@ function ContactRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: tag + actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-28 text-xs text-muted-foreground text-right">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||
}
|
||||
emptyText="Add tag"
|
||||
placeholder="work, home…"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
{/* Bottom / right: tag + actions.
|
||||
Two layers of hiding compose here:
|
||||
(a) phoneEditing — when the phone editor is open, hide the entire
|
||||
action cluster (tag + star + trash) so the user can focus on
|
||||
the form without chips fighting for space.
|
||||
(b) contact.value — when the value is empty (stale import row,
|
||||
aborted edit), hide just the tag + Make-primary star;
|
||||
neither makes sense without a value. The trash icon stays
|
||||
so the user can clean up the empty entry.
|
||||
On touch (no hover), trash is always rendered; on desktop it
|
||||
fades in on hover only (sm:opacity-0 + sm:group-hover:opacity-100). */}
|
||||
{!phoneEditing ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
{contact.value ? (
|
||||
<>
|
||||
<div className="w-28 text-right text-xs text-muted-foreground">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary'
|
||||
? contact.label
|
||||
: null
|
||||
}
|
||||
emptyText="Add tag"
|
||||
placeholder="work, home…"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'rounded p-1 transition-colors hover:bg-background/60',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-background/60 transition-colors',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -330,7 +363,9 @@ function NewContactForm({
|
||||
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
// Single row on sm+; wraps onto multiple lines below 640px so the channel
|
||||
// picker, value field, label, and buttons each get their own usable width.
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
|
||||
<Select
|
||||
value={channel}
|
||||
onValueChange={(next) => {
|
||||
@@ -353,7 +388,7 @@ function NewContactForm({
|
||||
</Select>
|
||||
|
||||
{isPhoneChannel ? (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
||||
<PhoneInput
|
||||
value={phoneValue}
|
||||
onChange={(v) => setPhoneValue(v)}
|
||||
@@ -365,7 +400,7 @@ function NewContactForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
||||
className="h-7 text-sm flex-1 min-w-0"
|
||||
className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
@@ -382,7 +417,7 @@ function NewContactForm({
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="tag (optional)"
|
||||
className="h-7 text-xs w-28"
|
||||
className="h-7 w-28 text-xs"
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -393,12 +428,14 @@ function NewContactForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<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 items-center gap-2 flex-wrap">
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||
|
||||
@@ -146,7 +146,11 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Date">
|
||||
<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"
|
||||
onSave={save('incorporationDate')}
|
||||
/>
|
||||
|
||||
@@ -28,11 +28,10 @@ function formatPercent(value: number): string {
|
||||
|
||||
function KpiTileSkeleton() {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="mt-3 h-7 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-12" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,11 @@ export function MyRemindersRail() {
|
||||
return `/${portSlug}/reminders`;
|
||||
}
|
||||
|
||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
||||
// tall empty card.
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card className="xl:h-full">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
|
||||
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type Edge = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
interface ToolbarState {
|
||||
edge: Edge;
|
||||
ratio: number;
|
||||
collapsed: boolean;
|
||||
enabled: boolean;
|
||||
defaultAction?: string;
|
||||
}
|
||||
|
||||
interface ReactGrabAPI {
|
||||
setToolbarState: (state: Partial<ToolbarState>) => void;
|
||||
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__REACT_GRAB__?: ReactGrabAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
|
||||
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
|
||||
|
||||
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
|
||||
edge: 'bottom',
|
||||
ratio: 0.5,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
const MOBILE_DEFAULT: Partial<ToolbarState> = {
|
||||
edge: 'right',
|
||||
ratio: 0.5,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
export function ReactGrabViewportSync() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'development') return;
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
let pollId: number | undefined;
|
||||
|
||||
const wireUp = (api: ReactGrabAPI) => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
|
||||
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
|
||||
|
||||
let suppressNextWrite = false;
|
||||
const apply = () => {
|
||||
const stored = localStorage.getItem(keyFor());
|
||||
suppressNextWrite = true;
|
||||
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
const unsubscribe = api.onToolbarStateChange((state) => {
|
||||
if (suppressNextWrite) {
|
||||
suppressNextWrite = false;
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(keyFor(), JSON.stringify(state));
|
||||
});
|
||||
|
||||
mql.addEventListener('change', apply);
|
||||
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
|
||||
};
|
||||
|
||||
const tryWire = () => {
|
||||
const api = window.__REACT_GRAB__;
|
||||
if (!api) return false;
|
||||
wireUp(api);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryWire()) {
|
||||
pollId = window.setInterval(() => {
|
||||
if (tryWire() && pollId !== undefined) {
|
||||
window.clearInterval(pollId);
|
||||
pollId = undefined;
|
||||
}
|
||||
}, 100);
|
||||
window.setTimeout(() => {
|
||||
if (pollId !== undefined) {
|
||||
window.clearInterval(pollId);
|
||||
pollId = undefined;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollId !== undefined) window.clearInterval(pollId);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -339,12 +339,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: icon-only with title tooltip + colored fill carries
|
||||
the won/lost meaning (green vs rose). Adding a "Won" /
|
||||
"Lost" text label inline blew out the cluster width and
|
||||
forced the Email/Call/WhatsApp action-chip row above to
|
||||
stack vertically — bad trade. From sm up, the full
|
||||
"Mark won" / "Close as lost" labels read clearly. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('won')}
|
||||
aria-label="Mark as won"
|
||||
title="Mark as won"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
|
||||
'border border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
'hover:bg-emerald-100',
|
||||
)}
|
||||
@@ -356,8 +363,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('lost')}
|
||||
aria-label="Close as lost"
|
||||
title="Close as lost"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
|
||||
'border border-rose-200 text-rose-700',
|
||||
'hover:bg-rose-50',
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react';
|
||||
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,11 +12,27 @@ type TabSpec = {
|
||||
segment: string; // route segment after /[portSlug]/
|
||||
};
|
||||
|
||||
// Bottom nav ordering, left → right:
|
||||
// Dashboard — daily overview
|
||||
// Berths — marina inventory grid (touches sales + ops both)
|
||||
// Clients — the address book / dedup surface (centered: it's the
|
||||
// primary mental anchor for "find this person", with
|
||||
// interests living as a tab on the client detail rather
|
||||
// than a peer in the bottom nav)
|
||||
// Documents — signature tracking (chase signers, EOI queue)
|
||||
// More — overflow drawer (Interests, Yachts, Companies, …)
|
||||
//
|
||||
// Interests is intentionally NOT in the bottom row — having both Clients
|
||||
// and Interests as peer tabs created a Clients-vs-Interests confusion
|
||||
// for sales reps, and the per-client interests tab + the new bottom-sheet
|
||||
// drawer cover the day-to-day deal review without needing a dedicated tab.
|
||||
// Yachts stays out for the same reason as before: it's an asset record
|
||||
// most often reached from inside an interest or client, not browsed.
|
||||
const TABS: TabSpec[] = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
];
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Building2,
|
||||
Bookmark,
|
||||
Receipt,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Mail,
|
||||
Bell,
|
||||
ShieldAlert,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileText,
|
||||
Mail,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -30,13 +30,18 @@ type MoreItem = {
|
||||
segment: string;
|
||||
};
|
||||
|
||||
// Order: most-likely overflow targets first. Interests is here (rather
|
||||
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
|
||||
// reps reach the active deals via the Interests tab on a client detail
|
||||
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
|
||||
// best reached contextually from inside an interest or client.
|
||||
const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Documents', icon: FolderOpen, segment: 'documents' },
|
||||
{ label: 'Email', icon: Mail, segment: 'email' },
|
||||
{ label: 'Inbox', icon: Mail, segment: 'email' },
|
||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
{ label: 'Reminders', icon: Bell, segment: 'reminders' },
|
||||
|
||||
@@ -249,7 +249,9 @@ export function ReminderList() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{/* Wrap on phone widths so the priority filter doesn't get pushed
|
||||
off-screen by the My/All tabs + status filter taking the full row. */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 sm:gap-4">
|
||||
{canViewAll && (
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
|
||||
<TabsList>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ResidentialClientRow {
|
||||
@@ -85,7 +86,9 @@ export function ResidentialClientsList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
|
||||
off the viewport at phone widths. */}
|
||||
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
@@ -137,6 +140,51 @@ export function ResidentialClientsList() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: card list. Each card mirrors the table row data with
|
||||
name + status pill on top, then meta line(s) below. */}
|
||||
<div className="lg:hidden space-y-2">
|
||||
{isLoading && (
|
||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No residential clients yet.
|
||||
</div>
|
||||
)}
|
||||
{data?.data.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/residential/clients/${c.id}` as any}
|
||||
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-sm truncate">{c.fullName}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
c.status === 'active'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: c.status === 'inactive'
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-blue-100 text-blue-800',
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[c.status] ?? c.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{c.email ? <span className="truncate">{c.email}</span> : null}
|
||||
{c.phone ? <span>{c.phone}</span> : null}
|
||||
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
|
||||
{c.source ? <span className="capitalize">· {c.source}</span> : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,8 @@ export function ResidentialInterestsList() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* Desktop: table layout. Hidden below lg; mobile renders cards. */}
|
||||
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
@@ -149,6 +150,47 @@ export function ResidentialInterestsList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: card list. Stage as the headline (it's the most actionable
|
||||
field for triage), preferences/notes truncated below. */}
|
||||
<div className="lg:hidden space-y-2">
|
||||
{isLoading && (
|
||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No interests match.
|
||||
</div>
|
||||
)}
|
||||
{data?.data.map((i) => (
|
||||
<Link
|
||||
key={i.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-sm">
|
||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||
</p>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{new Date(i.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{i.preferences ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{i.preferences}</p>
|
||||
) : null}
|
||||
{i.notes ? (
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground/80">{i.notes}</p>
|
||||
) : null}
|
||||
{i.source ? (
|
||||
<p className="mt-1 text-[11px] capitalize text-muted-foreground">{i.source}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -225,6 +225,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
|
||||
function flagEmoji(code: string | null | undefined): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const A = 0x1f1e6;
|
||||
const a = 'A'.charCodeAt(0);
|
||||
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
|
||||
}
|
||||
|
||||
function CountryFieldInline({
|
||||
value,
|
||||
onSave,
|
||||
@@ -233,20 +241,34 @@ function CountryFieldInline({
|
||||
onSave: (iso: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Tracks whether a value was picked this edit cycle so the open-change
|
||||
// handler doesn't double-exit while commit is still in flight.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<CountryCombobox
|
||||
value={value ?? null}
|
||||
onChange={async (iso) => {
|
||||
pickedRef.current = true;
|
||||
setEditing(false);
|
||||
await onSave(iso ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
// Drop the user straight into the picker — no extra click on the
|
||||
// trigger required.
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// Auto-exit edit mode when the popover closes without a pick so
|
||||
// the user isn't stuck staring at a "Select country…" trigger.
|
||||
if (!open && !pickedRef.current) setEditing(false);
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const display = value ? getCountryName(value, 'en') : null;
|
||||
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -268,17 +290,25 @@ function SubdivisionFieldInline({
|
||||
onSave: (code: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<SubdivisionCombobox
|
||||
value={value ?? null}
|
||||
country={country}
|
||||
onChange={async (code) => {
|
||||
pickedRef.current = true;
|
||||
setEditing(false);
|
||||
await onSave(code ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pickedRef.current) setEditing(false);
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ interface CountryComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers so the
|
||||
* user lands directly in the picker after clicking the edit affordance. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,8 +64,14 @@ export function CountryCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: CountryComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
// Pre-build the options list once per locale change so the cmdk filter
|
||||
@@ -75,7 +87,7 @@ export function CountryCombobox({
|
||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -31,8 +31,12 @@ export function InlineCountryField({
|
||||
}: InlineCountryFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Set true when the user picks a value from the dropdown, so the
|
||||
// popover-close handler knows commit() will exit edit mode itself.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
async function commit(next: CountryCode | null) {
|
||||
pickedRef.current = true;
|
||||
if (next === (value ?? null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
@@ -51,7 +55,23 @@ export function InlineCountryField({
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} />
|
||||
<CountryCombobox
|
||||
value={value}
|
||||
onChange={(iso) => void commit(iso)}
|
||||
data-testid={testId}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// When the dropdown closes without a selection, leave edit mode
|
||||
// so the user isn't stuck staring at the trigger button. If a
|
||||
// pick happened, commit() handles the exit (and may need to keep
|
||||
// edit mode briefly to show the saving spinner).
|
||||
if (!open && !pickedRef.current) {
|
||||
setEditing(false);
|
||||
}
|
||||
// Reset for the next open cycle.
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,12 @@ interface InlinePhoneFieldProps {
|
||||
/** Falls back to this country if `country` isn't set. */
|
||||
defaultCountry?: CountryCode;
|
||||
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
|
||||
/**
|
||||
* Notifies the parent when the field enters/exits edit mode. Lets the row
|
||||
* dim or hide noise (tag chips, action buttons) while the user is focused
|
||||
* on the editor.
|
||||
*/
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -28,12 +34,13 @@ export function InlinePhoneField({
|
||||
country,
|
||||
defaultCountry,
|
||||
onSave,
|
||||
onEditingChange,
|
||||
emptyText = '—',
|
||||
disabled,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: InlinePhoneFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editing, setEditingRaw] = useState(false);
|
||||
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
|
||||
if (!e164 && !country) return null;
|
||||
return {
|
||||
@@ -43,6 +50,11 @@ export function InlinePhoneField({
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function setEditing(next: boolean) {
|
||||
setEditingRaw(next);
|
||||
onEditingChange?.(next);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
|
||||
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
|
||||
@@ -62,39 +74,50 @@ export function InlinePhoneField({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
// Two clean lines: country picker + number on top, action pair below.
|
||||
<div className={cn('flex w-full flex-col gap-2.5', className)}>
|
||||
<PhoneInput
|
||||
value={draft}
|
||||
onChange={(v) => setDraft(v)}
|
||||
defaultCountry={defaultCountry}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving}
|
||||
className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(
|
||||
e164 || country
|
||||
? {
|
||||
e164: e164 ?? null,
|
||||
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setEditing(false);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(
|
||||
e164 || country
|
||||
? {
|
||||
e164: e164 ?? null,
|
||||
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setEditing(false);
|
||||
}}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
|
||||
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
|
||||
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
|
||||
'transition-colors hover:bg-primary/90 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -31,8 +31,12 @@ export function InlineTimezoneField({
|
||||
}: InlineTimezoneFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Set true when the user picks a value from the dropdown, so the
|
||||
// popover-close handler knows commit() will exit edit mode itself.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
async function commit(next: string | null) {
|
||||
pickedRef.current = true;
|
||||
if (next === (value ?? null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
@@ -56,6 +60,16 @@ export function InlineTimezoneField({
|
||||
onChange={(tz) => void commit(tz)}
|
||||
countryHint={countryHint ?? undefined}
|
||||
data-testid={testId}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// Auto-exit edit mode when the dropdown closes without a pick,
|
||||
// so the user isn't stuck looking at the trigger. commit() owns
|
||||
// the exit when a value was selected.
|
||||
if (!open && !pickedRef.current) {
|
||||
setEditing(false);
|
||||
}
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface ResponsiveTab {
|
||||
id: string;
|
||||
@@ -26,47 +19,56 @@ interface ResponsiveTabsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs that collapse to a native <Select> on phone-sized viewports.
|
||||
* Above sm: TabsList renders. At/below sm: a Select dropdown replaces the tab strip.
|
||||
* Tab strip that scrolls horizontally on narrow viewports. The active tab is
|
||||
* automatically scrolled into view so users can tell at a glance that more
|
||||
* tabs exist beyond the visible edge.
|
||||
*
|
||||
* Previously this collapsed to a <Select> on phone widths, but that read as
|
||||
* a generic dropdown and obscured the fact that multiple peer tabs exist.
|
||||
*/
|
||||
export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the active trigger in view when the value changes externally
|
||||
// (e.g. ?tab= in the URL or a back/forward navigation).
|
||||
useEffect(() => {
|
||||
const root = listRef.current;
|
||||
if (!root) return;
|
||||
const active = root.querySelector<HTMLButtonElement>(`[data-tab-id="${CSS.escape(value)}"]`);
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onValueChange}>
|
||||
{/* Mobile: select dropdown */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabs.map((tab) => (
|
||||
<SelectItem key={tab.id} value={tab.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge !== null && (
|
||||
<span className="text-xs text-muted-foreground">({tab.badge})</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Single scrollable strip for all viewport widths.
|
||||
The wrapper handles horizontal overflow with momentum scroll on
|
||||
touch devices; the inner TabsList stays its natural width and
|
||||
slides under the wrapper. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<TabsList className="inline-flex w-max">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5 whitespace-nowrap"
|
||||
data-tab-id={tab.id}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge !== null && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Desktop / tablet: tab strip */}
|
||||
<TabsList className="hidden sm:flex">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge !== null && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
||||
{tab.content}
|
||||
|
||||
@@ -32,6 +32,11 @@ interface SubdivisionComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SubdivisionCombobox({
|
||||
@@ -44,8 +49,14 @@ export function SubdivisionCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: SubdivisionComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!country) return [];
|
||||
@@ -64,7 +75,7 @@ export function SubdivisionCombobox({
|
||||
else triggerLabel = placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -29,6 +29,11 @@ interface TimezoneComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TimezoneCombobox({
|
||||
@@ -41,8 +46,14 @@ export function TimezoneCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: TimezoneComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
return listAllTimezones().map((tz) => ({
|
||||
@@ -66,7 +77,7 @@ export function TimezoneCombobox({
|
||||
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function KPITile({
|
||||
<div
|
||||
data-testid="kpi-tile"
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
|
||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-3 shadow-sm transition-all duration-base ease-smooth hover:shadow-md sm:p-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -53,10 +53,12 @@ export function KPITile({
|
||||
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
|
||||
{value}
|
||||
</div>
|
||||
{typeof delta === 'number' ? (
|
||||
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
||||
{deltaPrefix}
|
||||
|
||||
@@ -142,7 +142,10 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<DetailHeaderStrip>
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
{/* Stacks vertically on phone widths so the action cluster doesn't
|
||||
crush the status pill / owner row. From sm up, title block sits
|
||||
beside actions in the original layout. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-3 sm:flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||
|
||||
@@ -123,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
|
||||
|
||||
export type BerthStatus = (typeof BERTH_STATUSES)[number];
|
||||
|
||||
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
|
||||
// Stored as free text in the DB so legacy values still load, but the form
|
||||
// presents only the canonical options below.
|
||||
|
||||
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||
|
||||
export const BERTH_SIDE_PONTOON_OPTIONS = [
|
||||
'No',
|
||||
'Quay SB',
|
||||
'Quay PT',
|
||||
'Quay SB, Yes PT',
|
||||
'Quay PT, Yes SB',
|
||||
'Yes SB',
|
||||
'Yes PT',
|
||||
'Yes SB, PT',
|
||||
'Finger SB',
|
||||
'Finger PT',
|
||||
] as const;
|
||||
|
||||
export const BERTH_MOORING_TYPES = [
|
||||
'Side Pier / Med Mooring',
|
||||
'2x Med Mooring',
|
||||
'Side Pier / Finger',
|
||||
'Finger / Med Mooring',
|
||||
'2x Finger',
|
||||
] as const;
|
||||
|
||||
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
|
||||
|
||||
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
|
||||
|
||||
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
|
||||
|
||||
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
|
||||
|
||||
export const BERTH_ACCESS_OPTIONS = [
|
||||
'Car to Vessel',
|
||||
'Car to Quai, Cart to Vessel',
|
||||
'Cart to Vessel',
|
||||
'Car (3t) to Vessel',
|
||||
'Car (3.5t) to Vessel',
|
||||
] as const;
|
||||
|
||||
// ─── Lead Categories ─────────────────────────────────────────────────────────
|
||||
|
||||
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
||||
|
||||
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal file
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Convert text columns to numeric. NULLs survive; empty strings become NULL;
|
||||
-- whitespace is trimmed before casting so legacy data with stray spaces converts cleanly.
|
||||
ALTER TABLE "berths"
|
||||
ALTER COLUMN "nominal_boat_size" SET DATA TYPE numeric
|
||||
USING NULLIF(TRIM("nominal_boat_size"), '')::numeric;--> statement-breakpoint
|
||||
ALTER TABLE "berths"
|
||||
ALTER COLUMN "nominal_boat_size_m" SET DATA TYPE numeric
|
||||
USING NULLIF(TRIM("nominal_boat_size_m"), '')::numeric;--> statement-breakpoint
|
||||
ALTER TABLE "berths"
|
||||
ALTER COLUMN "power_capacity" SET DATA TYPE numeric
|
||||
USING NULLIF(TRIM("power_capacity"), '')::numeric;--> statement-breakpoint
|
||||
ALTER TABLE "berths"
|
||||
ALTER COLUMN "voltage" SET DATA TYPE numeric
|
||||
USING NULLIF(TRIM("voltage"), '')::numeric;--> statement-breakpoint
|
||||
ALTER TABLE "berths" ADD COLUMN "status_override_mode" text;
|
||||
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,13 @@
|
||||
"tag": "0019_lazy_vampiro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1777814682110,
|
||||
"tag": "0020_medical_betty_brant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
|
||||
@@ -33,14 +33,15 @@ export const berths = pgTable(
|
||||
widthM: numeric('width_m'),
|
||||
draftM: numeric('draft_m'),
|
||||
widthIsMinimum: boolean('width_is_minimum').default(false),
|
||||
nominalBoatSize: text('nominal_boat_size'),
|
||||
nominalBoatSizeM: text('nominal_boat_size_m'),
|
||||
// Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
|
||||
nominalBoatSize: numeric('nominal_boat_size'),
|
||||
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||
waterDepth: numeric('water_depth'),
|
||||
waterDepthM: numeric('water_depth_m'),
|
||||
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
||||
sidePontoon: text('side_pontoon'),
|
||||
powerCapacity: text('power_capacity'),
|
||||
voltage: text('voltage'),
|
||||
powerCapacity: numeric('power_capacity'), // kW
|
||||
voltage: numeric('voltage'), // V at 60Hz
|
||||
mooringType: text('mooring_type'),
|
||||
cleatType: text('cleat_type'),
|
||||
cleatCapacity: text('cleat_capacity'),
|
||||
@@ -58,6 +59,9 @@ export const berths = pgTable(
|
||||
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
||||
statusLastChangedReason: text('status_last_changed_reason'),
|
||||
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
|
||||
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
|
||||
// Reserved for future "manual override" semantics; not surfaced in the UI today.
|
||||
statusOverrideMode: text('status_override_mode'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* Exports `seedPortData(portId, portSlug)` — creates a realistic,
|
||||
* multi-cardinality data fixture for one port:
|
||||
*
|
||||
* - 12 berths (5 available / 5 reserved-active / 2 sold)
|
||||
* - 117 berths imported from a snapshot of the legacy NocoDB Berths
|
||||
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
|
||||
* so the first 12 entries satisfy the index assumptions used further
|
||||
* down for interest/reservation linkage:
|
||||
* idx 0..4 — available (small)
|
||||
* idx 5..9 — under_offer (medium)
|
||||
* idx 10..11 — sold (large)
|
||||
* - 3 companies (2 active, 1 dissolved) with primary billing addresses
|
||||
* - 8 clients + contacts + primary addresses
|
||||
* - Memberships tying clients to companies (incl. multi-company + ended)
|
||||
@@ -39,6 +45,44 @@ import {
|
||||
getStandardEoiTemplateHtml,
|
||||
STANDARD_EOI_MERGE_FIELDS,
|
||||
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
||||
import berthSnapshot from './seed-data/berths.json';
|
||||
|
||||
// ─── Berth snapshot ──────────────────────────────────────────────────────────
|
||||
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
|
||||
// Refresh by re-running the snapshot script (see git history of this file).
|
||||
type SeedBerth = {
|
||||
legacyId: number;
|
||||
mooringNumber: string;
|
||||
legacyMooringNumber: string;
|
||||
area: string | null;
|
||||
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;
|
||||
};
|
||||
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
|
||||
return withTransaction(async (tx) => {
|
||||
// ── 1. Berths ──────────────────────────────────────────────────────────
|
||||
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold.
|
||||
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation")
|
||||
// and 10..11 as 'sold'; 0..4 remain 'available'.
|
||||
const BERTH_SPECS: Array<{
|
||||
mooring: string;
|
||||
area: string;
|
||||
lengthM: string;
|
||||
widthM: string;
|
||||
draftM: string;
|
||||
price: string;
|
||||
status: 'available' | 'under_offer' | 'sold';
|
||||
}> = [
|
||||
{
|
||||
mooring: 'A-01',
|
||||
area: 'North Pier',
|
||||
lengthM: '15',
|
||||
widthM: '5',
|
||||
draftM: '2.5',
|
||||
price: '250000',
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
mooring: 'A-02',
|
||||
area: 'North Pier',
|
||||
lengthM: '18',
|
||||
widthM: '5.5',
|
||||
draftM: '2.8',
|
||||
price: '320000',
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
mooring: 'A-03',
|
||||
area: 'North Pier',
|
||||
lengthM: '20',
|
||||
widthM: '6',
|
||||
draftM: '3.0',
|
||||
price: '420000',
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
mooring: 'B-01',
|
||||
area: 'Central Basin',
|
||||
lengthM: '25',
|
||||
widthM: '7',
|
||||
draftM: '3.5',
|
||||
price: '580000',
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
mooring: 'B-02',
|
||||
area: 'Central Basin',
|
||||
lengthM: '30',
|
||||
widthM: '8',
|
||||
draftM: '4.0',
|
||||
price: '780000',
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
mooring: 'B-03',
|
||||
area: 'Central Basin',
|
||||
lengthM: '35',
|
||||
widthM: '8.5',
|
||||
draftM: '4.2',
|
||||
price: '950000',
|
||||
status: 'under_offer',
|
||||
},
|
||||
{
|
||||
mooring: 'C-01',
|
||||
area: 'South Marina',
|
||||
lengthM: '40',
|
||||
widthM: '9',
|
||||
draftM: '4.5',
|
||||
price: '1250000',
|
||||
status: 'under_offer',
|
||||
},
|
||||
{
|
||||
mooring: 'C-02',
|
||||
area: 'South Marina',
|
||||
lengthM: '45',
|
||||
widthM: '10',
|
||||
draftM: '4.8',
|
||||
price: '1600000',
|
||||
status: 'under_offer',
|
||||
},
|
||||
{
|
||||
mooring: 'C-03',
|
||||
area: 'South Marina',
|
||||
lengthM: '50',
|
||||
widthM: '11',
|
||||
draftM: '5.0',
|
||||
price: '2100000',
|
||||
status: 'under_offer',
|
||||
},
|
||||
{
|
||||
mooring: 'D-01',
|
||||
area: 'Superyacht Dock',
|
||||
lengthM: '60',
|
||||
widthM: '13',
|
||||
draftM: '5.5',
|
||||
price: '3200000',
|
||||
status: 'under_offer',
|
||||
},
|
||||
{
|
||||
mooring: 'D-02',
|
||||
area: 'Superyacht Dock',
|
||||
lengthM: '70',
|
||||
widthM: '14',
|
||||
draftM: '6.0',
|
||||
price: '4500000',
|
||||
status: 'sold',
|
||||
},
|
||||
{
|
||||
mooring: 'D-03',
|
||||
area: 'Superyacht Dock',
|
||||
lengthM: '80',
|
||||
widthM: '15',
|
||||
draftM: '6.5',
|
||||
price: '6800000',
|
||||
status: 'sold',
|
||||
},
|
||||
];
|
||||
|
||||
// 117 berths seeded from the legacy NocoDB Berths snapshot.
|
||||
// The JSON file is pre-sorted so the first 12 indexes satisfy the
|
||||
// status semantics expected by the interest/reservation seeds:
|
||||
// idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
|
||||
const berthRows = await tx
|
||||
.insert(berths)
|
||||
.values(
|
||||
BERTH_SPECS.map((b) => ({
|
||||
BERTH_SNAPSHOT.map((b) => ({
|
||||
portId,
|
||||
mooringNumber: b.mooring,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthM: b.lengthM,
|
||||
widthM: b.widthM,
|
||||
draftM: b.draftM,
|
||||
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2),
|
||||
widthFt: (Number(b.widthM) * 3.28084).toFixed(2),
|
||||
draftFt: (Number(b.draftM) * 3.28084).toFixed(2),
|
||||
price: b.price,
|
||||
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,
|
||||
})),
|
||||
)
|
||||
|
||||
3746
src/lib/db/seed-data/berths.json
Normal file
3746
src/lib/db/seed-data/berths.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,16 @@
|
||||
* Seed script for Port Nimara CRM.
|
||||
*
|
||||
* Top-level orchestrator:
|
||||
* 1. Create 3 ports (idempotent):
|
||||
* - Port Nimara
|
||||
* - Marina Azzurra
|
||||
* - Harbor Royale
|
||||
* 1. Create the operational ports (idempotent):
|
||||
* - Port Nimara (primary install — the real marina)
|
||||
* - Port Amador (secondary, kept for multi-tenant isolation tests
|
||||
* and as scaffolding for a future Panama install)
|
||||
* 2. Create 5 system roles with full permission maps
|
||||
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
||||
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
||||
* to produce the realistic multi-cardinality fixture
|
||||
* (berths, clients, companies, yachts, memberships, interests,
|
||||
* reservations, ownership-transfer history).
|
||||
* (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
|
||||
* memberships, interests, reservations, ownership-transfer history).
|
||||
* 5. Print a summary.
|
||||
*
|
||||
* Run with: pnpm db:seed
|
||||
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
},
|
||||
// Second port kept for multi-tenant isolation tests (cross-port scoping,
|
||||
// permission boundaries). Drop or rename if the production install is
|
||||
// single-port.
|
||||
{
|
||||
name: 'Marina Azzurra',
|
||||
slug: 'marina-azzurra',
|
||||
primaryColor: '#2E86AB',
|
||||
defaultCurrency: 'EUR',
|
||||
timezone: 'Europe/Rome',
|
||||
},
|
||||
{
|
||||
name: 'Harbor Royale',
|
||||
slug: 'harbor-royale',
|
||||
primaryColor: '#8B1E3F',
|
||||
defaultCurrency: 'GBP',
|
||||
timezone: 'Europe/London',
|
||||
name: 'Port Amador',
|
||||
slug: 'port-amador',
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
362
src/lib/dedup/migration-apply.ts
Normal file
362
src/lib/dedup/migration-apply.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Apply phase for the legacy NocoDB → CRM migration. Walks a
|
||||
* `MigrationPlan` produced by {@link transformSnapshot} and writes
|
||||
* the new client / contact / address / yacht / interest rows into the
|
||||
* target port.
|
||||
*
|
||||
* Idempotent: every insert is guarded by a `migration_source_links`
|
||||
* lookup keyed on `(source_system, source_id, target_entity_type)`, so
|
||||
* a partial failure can be resumed by re-running the script. Re-runs
|
||||
* against an already-applied plan are a near-no-op.
|
||||
*
|
||||
* Per-entity transactions (not one giant transaction) — the design
|
||||
* favours visible partial progress on failure over all-or-nothing.
|
||||
*
|
||||
* @see src/lib/dedup/migration-transform.ts for the input shape.
|
||||
* @see src/lib/db/schema/migration.ts for the idempotency ledger.
|
||||
*/
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
import type { MigrationPlan, PlannedClient, PlannedInterest } from './migration-transform';
|
||||
|
||||
const SOURCE_SYSTEM = 'nocodb_interests';
|
||||
|
||||
/**
|
||||
* Convert a legacy bare mooring string like "D32" / "A1" / "E18" to the
|
||||
* dashed/padded form "D-32" / "A-01" / "E-18" used by the new berths
|
||||
* schema. If the input doesn't match the bare pattern, returns it
|
||||
* unchanged so a literal lookup can still hit (handles the case where
|
||||
* the legacy data already has the dashed form).
|
||||
*
|
||||
* Multi-mooring strings ("A3, D30") return the original string —
|
||||
* those need human review and we don't want to silently pick one half.
|
||||
*/
|
||||
function normalizeLegacyMooring(raw: string): string {
|
||||
// Bare letter+digits, e.g. "D32"
|
||||
const m = /^([A-E])(\d{1,3})$/i.exec(raw.trim());
|
||||
if (!m) return raw;
|
||||
const letter = m[1]!.toUpperCase();
|
||||
const num = parseInt(m[2]!, 10);
|
||||
return `${letter}-${num.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
applyId: string;
|
||||
clientsInserted: number;
|
||||
clientsSkipped: number;
|
||||
contactsInserted: number;
|
||||
addressesInserted: number;
|
||||
yachtsInserted: number;
|
||||
interestsInserted: number;
|
||||
interestsSkipped: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ApplyOptions {
|
||||
port: { id: string; slug: string };
|
||||
applyId: string;
|
||||
/** Set to true for the "preview the writes" mode — runs every read but
|
||||
* rolls back inserts. Useful for verifying mappings before committing. */
|
||||
rehearsal?: boolean;
|
||||
appliedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an existing migration link for a (sourceId, targetType) pair.
|
||||
* Returns the existing target entity id if already linked.
|
||||
*/
|
||||
async function resolveExistingLink(
|
||||
sourceId: number,
|
||||
targetEntityType: 'client' | 'interest' | 'yacht' | 'address',
|
||||
): Promise<string | null> {
|
||||
const rows = await db
|
||||
.select({ id: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, SOURCE_SYSTEM),
|
||||
eq(migrationSourceLinks.sourceId, String(sourceId)),
|
||||
eq(migrationSourceLinks.targetEntityType, targetEntityType),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Find the first sourceId in a cluster that's already linked to a client,
|
||||
* if any. The cluster might be larger than the previously-applied set if
|
||||
* the dedup algorithm collapsed an extra duplicate this run. */
|
||||
async function resolveExistingClusterClient(sourceIds: number[]): Promise<string | null> {
|
||||
if (sourceIds.length === 0) return null;
|
||||
const rows = await db
|
||||
.select({ id: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, SOURCE_SYSTEM),
|
||||
inArray(migrationSourceLinks.sourceId, sourceIds.map(String)),
|
||||
eq(migrationSourceLinks.targetEntityType, 'client'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** Apply a single PlannedClient — returns `{clientId, inserted}` so the
|
||||
* caller can wire interests against the (possibly pre-existing) record. */
|
||||
async function applyClient(
|
||||
planned: PlannedClient,
|
||||
opts: ApplyOptions,
|
||||
result: ApplyResult,
|
||||
): Promise<{ clientId: string; inserted: boolean }> {
|
||||
// Idempotency: if any source row in the cluster already mapped to a client,
|
||||
// reuse that record.
|
||||
const existing = await resolveExistingClusterClient(planned.sourceIds);
|
||||
if (existing) {
|
||||
result.clientsSkipped += 1;
|
||||
return { clientId: existing, inserted: false };
|
||||
}
|
||||
|
||||
if (opts.rehearsal) {
|
||||
// Simulate an insert without writing — used for the preview path.
|
||||
return { clientId: `rehearsal-${planned.tempId}`, inserted: true };
|
||||
}
|
||||
|
||||
// surnameToken is on the planned object (used by the dedup blocking
|
||||
// index inside the transform) but not in the clients schema — runtime
|
||||
// dedup re-derives it from fullName when needed. Drop it on insert.
|
||||
const [inserted] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId: opts.port.id,
|
||||
fullName: planned.fullName,
|
||||
nationalityIso: planned.countryIso ?? null,
|
||||
preferredContactMethod: planned.preferredContactMethod ?? null,
|
||||
source: planned.source ?? null,
|
||||
})
|
||||
.returning({ id: clients.id });
|
||||
|
||||
if (!inserted) throw new Error('Client insert returned no row');
|
||||
const clientId = inserted.id;
|
||||
|
||||
// Record idempotency links — one per source row in the cluster.
|
||||
await db.insert(migrationSourceLinks).values(
|
||||
planned.sourceIds.map((sid) => ({
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceId: String(sid),
|
||||
targetEntityType: 'client' as const,
|
||||
targetEntityId: clientId,
|
||||
appliedId: opts.applyId,
|
||||
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
|
||||
})),
|
||||
);
|
||||
|
||||
// Contacts: bulk insert; mark first email + first phone as primary.
|
||||
if (planned.contacts.length > 0) {
|
||||
let primaryEmailSet = false;
|
||||
let primaryPhoneSet = false;
|
||||
const contactRows = planned.contacts.map((ct) => {
|
||||
let isPrimary = false;
|
||||
if (ct.isPrimary) {
|
||||
if (ct.channel === 'email' && !primaryEmailSet) {
|
||||
isPrimary = true;
|
||||
primaryEmailSet = true;
|
||||
} else if ((ct.channel === 'phone' || ct.channel === 'whatsapp') && !primaryPhoneSet) {
|
||||
isPrimary = true;
|
||||
primaryPhoneSet = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientId,
|
||||
channel: ct.channel,
|
||||
value: ct.value,
|
||||
valueE164: ct.valueE164 ?? null,
|
||||
valueCountry: ct.valueCountry ?? null,
|
||||
isPrimary,
|
||||
};
|
||||
});
|
||||
await db.insert(clientContacts).values(contactRows);
|
||||
result.contactsInserted += contactRows.length;
|
||||
}
|
||||
|
||||
// Addresses: bulk insert; first is marked primary if multiple. Note the
|
||||
// schema requires portId on every address row in addition to clientId.
|
||||
if (planned.addresses.length > 0) {
|
||||
const addressRows = planned.addresses.map((a, idx) => ({
|
||||
clientId,
|
||||
portId: opts.port.id,
|
||||
streetAddress: a.streetAddress ?? null,
|
||||
city: a.city ?? null,
|
||||
countryIso: a.countryIso ?? null,
|
||||
isPrimary: idx === 0,
|
||||
}));
|
||||
await db.insert(clientAddresses).values(addressRows);
|
||||
result.addressesInserted += addressRows.length;
|
||||
}
|
||||
|
||||
result.clientsInserted += 1;
|
||||
return { clientId, inserted: true };
|
||||
}
|
||||
|
||||
/** Apply a single PlannedInterest — looks up its client + berth + yacht and
|
||||
* inserts the interest row, plus a yacht stub if a yacht name is present. */
|
||||
async function applyInterest(
|
||||
planned: PlannedInterest,
|
||||
tempIdToClientId: Map<string, string>,
|
||||
mooringToBerthId: Map<string, string>,
|
||||
opts: ApplyOptions,
|
||||
result: ApplyResult,
|
||||
): Promise<void> {
|
||||
// Idempotency: skip if this source row already created an interest.
|
||||
const existing = await resolveExistingLink(planned.sourceId, 'interest');
|
||||
if (existing) {
|
||||
result.interestsSkipped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = tempIdToClientId.get(planned.clientTempId);
|
||||
if (!clientId) {
|
||||
result.warnings.push(
|
||||
`Interest source=${planned.sourceId} references unknown client tempId=${planned.clientTempId} — skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let berthId: string | null = null;
|
||||
if (planned.berthMooringNumber) {
|
||||
berthId =
|
||||
mooringToBerthId.get(planned.berthMooringNumber) ??
|
||||
// The legacy NocoDB Interests table uses bare mooring strings like
|
||||
// "D32", "B16", whereas the new berths schema (mirroring the NocoDB
|
||||
// Berths snapshot) uses zero-padded "D-32", "B-16". Try the dashed
|
||||
// form as a fallback so legacy references resolve correctly.
|
||||
mooringToBerthId.get(normalizeLegacyMooring(planned.berthMooringNumber)) ??
|
||||
null;
|
||||
if (!berthId) {
|
||||
result.warnings.push(
|
||||
`Interest source=${planned.sourceId} references unknown mooring="${planned.berthMooringNumber}" — interest created without berth link`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional yacht stub: if the legacy row had a yacht name, create a
|
||||
// minimal yacht record owned by the client. The new schema requires
|
||||
// currentOwnerType + currentOwnerId.
|
||||
let yachtId: string | null = null;
|
||||
if (planned.yachtName) {
|
||||
const existingYacht = await resolveExistingLink(planned.sourceId, 'yacht');
|
||||
if (existingYacht) {
|
||||
yachtId = existingYacht;
|
||||
} else if (!opts.rehearsal) {
|
||||
const [y] = await db
|
||||
.insert(yachts)
|
||||
.values({
|
||||
portId: opts.port.id,
|
||||
name: planned.yachtName,
|
||||
currentOwnerType: 'client',
|
||||
currentOwnerId: clientId,
|
||||
status: 'active',
|
||||
})
|
||||
.returning({ id: yachts.id });
|
||||
if (y) {
|
||||
yachtId = y.id;
|
||||
await db.insert(migrationSourceLinks).values({
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceId: String(planned.sourceId),
|
||||
targetEntityType: 'yacht' as const,
|
||||
targetEntityId: y.id,
|
||||
appliedId: opts.applyId,
|
||||
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
|
||||
});
|
||||
result.yachtsInserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.rehearsal) {
|
||||
result.interestsInserted += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const [iRow] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId: opts.port.id,
|
||||
clientId,
|
||||
berthId,
|
||||
yachtId,
|
||||
pipelineStage: planned.pipelineStage,
|
||||
leadCategory: planned.leadCategory,
|
||||
source: planned.source,
|
||||
notes: planned.notes,
|
||||
documensoId: planned.documensoId,
|
||||
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
|
||||
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,
|
||||
dateContractSent: planned.dateContractSent ? new Date(planned.dateContractSent) : null,
|
||||
dateContractSigned: planned.dateContractSigned ? new Date(planned.dateContractSigned) : null,
|
||||
dateDepositReceived: planned.dateDepositReceived
|
||||
? new Date(planned.dateDepositReceived)
|
||||
: null,
|
||||
dateLastContact: planned.dateLastContact ? new Date(planned.dateLastContact) : null,
|
||||
})
|
||||
.returning({ id: interests.id });
|
||||
|
||||
if (!iRow) throw new Error('Interest insert returned no row');
|
||||
|
||||
await db.insert(migrationSourceLinks).values({
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceId: String(planned.sourceId),
|
||||
targetEntityType: 'interest' as const,
|
||||
targetEntityId: iRow.id,
|
||||
appliedId: opts.applyId,
|
||||
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
|
||||
});
|
||||
|
||||
result.interestsInserted += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level apply driver. Walks the plan once, building the
|
||||
* tempId→clientId map as it goes, then walks interests with that map.
|
||||
*/
|
||||
export async function applyPlan(plan: MigrationPlan, opts: ApplyOptions): Promise<ApplyResult> {
|
||||
const result: ApplyResult = {
|
||||
applyId: opts.applyId,
|
||||
clientsInserted: 0,
|
||||
clientsSkipped: 0,
|
||||
contactsInserted: 0,
|
||||
addressesInserted: 0,
|
||||
yachtsInserted: 0,
|
||||
interestsInserted: 0,
|
||||
interestsSkipped: 0,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// 1. Clients (and their contacts/addresses)
|
||||
const tempIdToClientId = new Map<string, string>();
|
||||
for (const planned of plan.clients) {
|
||||
const { clientId } = await applyClient(planned, opts, result);
|
||||
tempIdToClientId.set(planned.tempId, clientId);
|
||||
}
|
||||
|
||||
// 2. Build mooring→berthId lookup once, scoped to this port.
|
||||
const berthRows = await db
|
||||
.select({ id: berths.id, mooringNumber: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, opts.port.id));
|
||||
const mooringToBerthId = new Map(berthRows.map((b) => [b.mooringNumber, b.id]));
|
||||
|
||||
// 3. Interests (and yacht stubs)
|
||||
for (const planned of plan.interests) {
|
||||
await applyInterest(planned, tempIdToClientId, mooringToBerthId, opts, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -58,10 +58,11 @@ export function buildPipelineInputs(
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
];
|
||||
|
||||
@@ -73,9 +74,7 @@ export function buildPipelineInputs(
|
||||
});
|
||||
|
||||
// Include stages not in standard order
|
||||
const unknownStages = Object.keys(data.stageCounts).filter(
|
||||
(s) => !stageOrder.includes(s),
|
||||
);
|
||||
const unknownStages = Object.keys(data.stageCounts).filter((s) => !stageOrder.includes(s));
|
||||
for (const stage of unknownStages) {
|
||||
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
|
||||
}
|
||||
|
||||
@@ -50,18 +50,16 @@ export const revenueReportTemplate: Template = {
|
||||
],
|
||||
};
|
||||
|
||||
export function buildRevenueInputs(
|
||||
data: RevenueData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
export function buildRevenueInputs(data: RevenueData, portName?: string): Record<string, string>[] {
|
||||
const stageOrder = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
];
|
||||
|
||||
|
||||
@@ -84,6 +84,28 @@ export const webhooksWorker = new Worker(
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety net: when EMAIL_REDIRECT_TO is set (dev / staging / migration
|
||||
// dry-run), short-circuit webhook delivery so we don't accidentally
|
||||
// ping a user-configured production endpoint with synthetic events.
|
||||
// Records the delivery as `dead_letter` with a clear reason so the
|
||||
// attempt is still visible in the deliveries listing.
|
||||
if (process.env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{ webhookId, deliveryId, url: webhook.url },
|
||||
'Webhook delivery skipped (EMAIL_REDIRECT_TO is set — outbound comms are paused)',
|
||||
);
|
||||
await db
|
||||
.update(webhookDeliveries)
|
||||
.set({
|
||||
status: 'dead_letter',
|
||||
responseStatus: null,
|
||||
responseBody: 'Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused.',
|
||||
deliveredAt: new Date(),
|
||||
})
|
||||
.where(eq(webhookDeliveries.id, deliveryId));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Decrypt secret
|
||||
let secret: string;
|
||||
try {
|
||||
|
||||
@@ -180,14 +180,14 @@ export async function updateBerth(
|
||||
draftFt: n(data.draftFt),
|
||||
draftM: n(data.draftM),
|
||||
widthIsMinimum: data.widthIsMinimum,
|
||||
nominalBoatSize: data.nominalBoatSize,
|
||||
nominalBoatSizeM: data.nominalBoatSizeM,
|
||||
nominalBoatSize: n(data.nominalBoatSize),
|
||||
nominalBoatSizeM: n(data.nominalBoatSizeM),
|
||||
waterDepth: n(data.waterDepth),
|
||||
waterDepthM: n(data.waterDepthM),
|
||||
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
||||
sidePontoon: data.sidePontoon,
|
||||
powerCapacity: data.powerCapacity,
|
||||
voltage: data.voltage,
|
||||
powerCapacity: n(data.powerCapacity),
|
||||
voltage: n(data.voltage),
|
||||
mooringType: data.mooringType,
|
||||
cleatType: data.cleatType,
|
||||
cleatCapacity: data.cleatCapacity,
|
||||
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
priceCurrency: data.priceCurrency ?? 'USD',
|
||||
tenureType: data.tenureType ?? 'permanent',
|
||||
mooringType: data.mooringType,
|
||||
powerCapacity: data.powerCapacity,
|
||||
voltage: data.voltage,
|
||||
powerCapacity: data.powerCapacity?.toString(),
|
||||
voltage: data.voltage?.toString(),
|
||||
access: data.access,
|
||||
bowFacing: data.bowFacing,
|
||||
sidePontoon: data.sidePontoon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
clients,
|
||||
clientContacts,
|
||||
clientNotes,
|
||||
clientRelationships,
|
||||
clientTags,
|
||||
clientAddresses,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
@@ -81,7 +84,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
|
||||
const ids = result.data.map((r) => r.id);
|
||||
|
||||
const [yachtCounts, companyCounts] = await Promise.all([
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||
.from(yachts)
|
||||
@@ -99,18 +102,67 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
.from(companyMemberships)
|
||||
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
||||
.groupBy(companyMemberships.clientId),
|
||||
db
|
||||
.select({
|
||||
clientId: interests.clientId,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
updatedAt: interests.updatedAt,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
})
|
||||
.from(interests)
|
||||
.leftJoin(berths, eq(berths.id, interests.berthId))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
inArray(interests.clientId, ids),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(interests.updatedAt)),
|
||||
db
|
||||
.select({ clientId: interests.clientId, count: count() })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
inArray(interests.clientId, ids),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
)
|
||||
.groupBy(interests.clientId),
|
||||
]);
|
||||
|
||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
|
||||
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
|
||||
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
|
||||
for (const row of interestRows) {
|
||||
if (!latestInterestMap.has(row.clientId)) {
|
||||
latestInterestMap.set(row.clientId, {
|
||||
stage: row.pipelineStage,
|
||||
mooringNumber: row.mooringNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((row) => ({
|
||||
...row,
|
||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||
companyCount: companyCountMap.get(row.id) ?? 0,
|
||||
})),
|
||||
data: result.data.map((row) => {
|
||||
const latest = latestInterestMap.get(row.id);
|
||||
return {
|
||||
...row,
|
||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||
companyCount: companyCountMap.get(row.id) ?? 0,
|
||||
interestCount: interestCountMap.get(row.id) ?? 0,
|
||||
latestInterest: latest
|
||||
? {
|
||||
stage: latest.stage,
|
||||
mooringNumber: latest.mooringNumber,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +252,19 @@ export async function getClientById(id: string, portId: string) {
|
||||
|
||||
const portalEnabled = await isPortalEnabledForPort(portId);
|
||||
|
||||
// Counts surfaced for tab badges (Interests + Notes — Yachts/Companies/etc
|
||||
// get their counts from the corresponding row arrays we already fetched).
|
||||
const [interestCountRow] = await db
|
||||
.select({ count: count() })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
|
||||
);
|
||||
const [noteCountRow] = await db
|
||||
.select({ count: count() })
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, id));
|
||||
|
||||
return {
|
||||
...client,
|
||||
contacts,
|
||||
@@ -208,6 +273,8 @@ export async function getClientById(id: string, portId: string) {
|
||||
yachts: yachtRows,
|
||||
companies: membershipRows,
|
||||
activeReservations,
|
||||
interestCount: interestCountRow?.count ?? 0,
|
||||
noteCount: noteCountRow?.count ?? 0,
|
||||
clientPortalEnabled: portalEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,17 +87,72 @@ export interface DocumensoDocument {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
|
||||
* email so Documenso doesn't accidentally email real clients during a
|
||||
* data import / migration dry-run. Names are prefixed with the original
|
||||
* email so the recipient (you) can tell who would have received the doc.
|
||||
*
|
||||
* In production this env var is unset and recipients flow through unchanged.
|
||||
*/
|
||||
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
|
||||
if (!env.EMAIL_REDIRECT_TO) return recipients;
|
||||
return recipients.map((r) => ({
|
||||
...r,
|
||||
name: `${r.name} (was: ${r.email})`,
|
||||
email: env.EMAIL_REDIRECT_TO!,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same idea for the template-generate endpoint, which takes a payload
|
||||
* shape with recipient email/name nested inside `formValues` (Documenso
|
||||
* v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
|
||||
*/
|
||||
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!env.EMAIL_REDIRECT_TO) return payload;
|
||||
const out: Record<string, unknown> = { ...payload };
|
||||
// 2.x recipient shape
|
||||
if (Array.isArray(out.recipients)) {
|
||||
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
|
||||
...r,
|
||||
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
|
||||
email: env.EMAIL_REDIRECT_TO,
|
||||
}));
|
||||
}
|
||||
// v1.13 formValues shape — keys vary per template; key by anything that
|
||||
// looks like an email field. The conservative approach: only touch keys
|
||||
// that already hold a string and end with `Email` / `email`.
|
||||
if (out.formValues && typeof out.formValues === 'object') {
|
||||
const fv = { ...(out.formValues as Record<string, unknown>) };
|
||||
for (const key of Object.keys(fv)) {
|
||||
if (/email$/i.test(key) && typeof fv[key] === 'string') {
|
||||
fv[key] = env.EMAIL_REDIRECT_TO;
|
||||
}
|
||||
}
|
||||
out.formValues = fv;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
title: string,
|
||||
pdfBase64: string,
|
||||
recipients: DocumensoRecipient[],
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
const safeRecipients = applyRecipientRedirect(recipients);
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
|
||||
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
|
||||
);
|
||||
}
|
||||
return documensoFetch(
|
||||
'/api/v1/documents',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, document: pdfBase64, recipients }),
|
||||
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
|
||||
},
|
||||
portId,
|
||||
).then(normalizeDocument);
|
||||
@@ -108,17 +163,43 @@ export async function generateDocumentFromTemplate(
|
||||
payload: Record<string, unknown>,
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
const safePayload = applyPayloadRedirect(payload);
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{ templateId },
|
||||
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
|
||||
);
|
||||
}
|
||||
return documensoFetch(
|
||||
`/api/v1/templates/${templateId}/generate-document`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(safePayload),
|
||||
},
|
||||
portId,
|
||||
).then(normalizeDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell Documenso to actually email the document to its recipients. The
|
||||
* recipients themselves are set at create-time (and rerouted to
|
||||
* EMAIL_REDIRECT_TO when set), but this is a belt-and-braces guard for
|
||||
* documents that may have been created BEFORE the redirect was turned on
|
||||
* (i.e. real-recipient documents now triggered by an automation while
|
||||
* we're trying to hold comms). When the redirect is on we skip the API
|
||||
* call entirely and return a synthetic "still pending" response.
|
||||
*/
|
||||
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.warn(
|
||||
{ docId, portId, redirect: env.EMAIL_REDIRECT_TO },
|
||||
'sendDocument SKIPPED — EMAIL_REDIRECT_TO is set, outbound comms paused',
|
||||
);
|
||||
// Return the existing doc shape so downstream code doesn't see an
|
||||
// unexpected null. The document remains in DRAFT/PENDING from
|
||||
// Documenso's perspective.
|
||||
return getDocument(docId, portId);
|
||||
}
|
||||
return documensoFetch(
|
||||
`/api/v1/documents/${docId}/send`,
|
||||
{
|
||||
@@ -132,11 +213,23 @@ export async function getDocument(docId: string, portId?: string): Promise<Docum
|
||||
return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email a signing reminder to one recipient. Skipped entirely when
|
||||
* EMAIL_REDIRECT_TO is set — the recipient's stored email may still be
|
||||
* a real client address from before the redirect was enabled.
|
||||
*/
|
||||
export async function sendReminder(
|
||||
docId: string,
|
||||
signerId: string,
|
||||
portId?: string,
|
||||
): Promise<void> {
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.warn(
|
||||
{ docId, signerId, portId, redirect: env.EMAIL_REDIRECT_TO },
|
||||
'sendReminder SKIPPED — EMAIL_REDIRECT_TO is set, outbound comms paused',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await documensoFetch(
|
||||
`/api/v1/documents/${docId}/recipients/${signerId}/remind`,
|
||||
{
|
||||
|
||||
@@ -5,7 +5,9 @@ import { db } from '@/lib/db';
|
||||
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
|
||||
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { env } from '@/lib/env';
|
||||
import { NotFoundError, ForbiddenError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getDecryptedCredentials } from '@/lib/services/email-accounts.service';
|
||||
import { getPortEmailConfig } from '@/lib/services/port-config';
|
||||
import { sendEmail as sendSystemEmail } from '@/lib/email';
|
||||
@@ -127,12 +129,38 @@ export async function sendEmail(
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Safety net: when EMAIL_REDIRECT_TO is set, every recipient is rerouted
|
||||
// to that address and the subject is prefixed so the operator can see
|
||||
// who would have received the message. This service builds its OWN
|
||||
// transporter (per-account SMTP) so it doesn't go through sendEmail's
|
||||
// redirect — we apply the same logic here.
|
||||
const requestedTo = data.to.join(', ');
|
||||
const requestedCc = data.cc?.join(', ');
|
||||
const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo;
|
||||
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : requestedCc;
|
||||
const effectiveSubject = env.EMAIL_REDIRECT_TO
|
||||
? `[redirected from ${requestedTo}${requestedCc ? `, cc=${requestedCc}` : ''}] ${data.subject}`
|
||||
: data.subject;
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
portId,
|
||||
accountId: data.accountId,
|
||||
originalTo: requestedTo,
|
||||
originalCc: requestedCc ?? null,
|
||||
redirectedTo: env.EMAIL_REDIRECT_TO,
|
||||
},
|
||||
'email-compose redirected to EMAIL_REDIRECT_TO',
|
||||
);
|
||||
}
|
||||
|
||||
// Send via the user's SMTP transporter
|
||||
const info = await transporter.sendMail({
|
||||
from: account.emailAddress,
|
||||
to: data.to.join(', '),
|
||||
cc: data.cc?.join(', '),
|
||||
subject: data.subject,
|
||||
to: effectiveTo,
|
||||
cc: effectiveCc,
|
||||
subject: effectiveSubject,
|
||||
html: data.bodyHtml,
|
||||
inReplyTo,
|
||||
references,
|
||||
|
||||
@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
|
||||
status: z.enum(BERTH_STATUSES).default('available'),
|
||||
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
||||
mooringType: z.string().optional(),
|
||||
powerCapacity: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
powerCapacity: z.coerce.number().optional(), // kW
|
||||
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||
access: z.string().optional(),
|
||||
bowFacing: z.string().optional(),
|
||||
sidePontoon: z.string().optional(),
|
||||
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
|
||||
draftFt: z.coerce.number().optional(),
|
||||
draftM: z.coerce.number().optional(),
|
||||
widthIsMinimum: z.boolean().optional(),
|
||||
nominalBoatSize: z.string().optional(),
|
||||
nominalBoatSizeM: z.string().optional(),
|
||||
nominalBoatSize: z.coerce.number().optional(), // ft
|
||||
nominalBoatSizeM: z.coerce.number().optional(), // m
|
||||
waterDepth: z.coerce.number().optional(),
|
||||
waterDepthM: z.coerce.number().optional(),
|
||||
waterDepthIsMinimum: z.boolean().optional(),
|
||||
sidePontoon: z.string().optional(),
|
||||
powerCapacity: z.string().optional(),
|
||||
voltage: z.string().optional(),
|
||||
powerCapacity: z.coerce.number().optional(), // kW
|
||||
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||
mooringType: z.string().optional(),
|
||||
cleatType: z.string().optional(),
|
||||
cleatCapacity: z.string().optional(),
|
||||
|
||||
@@ -635,10 +635,11 @@ export function makeCreateInterestInput(overrides?: {
|
||||
| 'open'
|
||||
| 'details_sent'
|
||||
| 'in_communication'
|
||||
| 'visited'
|
||||
| 'signed_eoi_nda'
|
||||
| 'eoi_sent'
|
||||
| 'eoi_signed'
|
||||
| 'deposit_10pct'
|
||||
| 'contract'
|
||||
| 'contract_sent'
|
||||
| 'contract_signed'
|
||||
| 'completed';
|
||||
}) {
|
||||
return {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('alert engine', () => {
|
||||
await db.insert(interests).values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
pipelineStage: 'visited',
|
||||
pipelineStage: 'in_communication',
|
||||
leadCategory: 'hot_lead',
|
||||
dateLastContact: stale,
|
||||
updatedAt: stale,
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('Pipeline Transitions', () => {
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta);
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'eoi_signed' }, meta);
|
||||
|
||||
const updated = await getInterestById(interestId, portId);
|
||||
expect(updated.dateEoiSigned).not.toBeNull();
|
||||
@@ -181,7 +181,7 @@ describe('Pipeline Transitions', () => {
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta);
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract_signed' }, meta);
|
||||
|
||||
const updated = await getInterestById(interestId, portId);
|
||||
expect(updated.dateContractSigned).not.toBeNull();
|
||||
|
||||
247
tests/unit/comms-safety.test.ts
Normal file
247
tests/unit/comms-safety.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* EMAIL_REDIRECT_TO safety net — comprehensive verification.
|
||||
*
|
||||
* Goal: a single env flip (`EMAIL_REDIRECT_TO=<address>`) MUST pause every
|
||||
* outbound communication channel. This test file exercises each channel
|
||||
* end-to-end with the env set, asserting the message is rerouted (or
|
||||
* short-circuited) before it leaves the process.
|
||||
*
|
||||
* Lock these tests in: any new outbound channel added later should ALSO
|
||||
* gain a check here. If a future PR breaks the redirect, this fails loud.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const REDIRECT_TARGET = 'redirect@example.test';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Documenso recipient redirect (createDocument + generateDocumentFromTemplate)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
|
||||
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
|
||||
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
|
||||
process.env.DOCUMENSO_API_URL = 'https://documenso.example.test';
|
||||
process.env.DOCUMENSO_API_KEY = 'test-key';
|
||||
|
||||
fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'doc-1',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
}),
|
||||
text: async () => '',
|
||||
}));
|
||||
// @ts-expect-error global fetch shim for the test
|
||||
globalThis.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
|
||||
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
|
||||
if (originalDocumensoUrl === undefined) delete process.env.DOCUMENSO_API_URL;
|
||||
else process.env.DOCUMENSO_API_URL = originalDocumensoUrl;
|
||||
if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY;
|
||||
else process.env.DOCUMENSO_API_KEY = originalDocumensoKey;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('createDocument — every recipient.email rewritten to redirect target', async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.createDocument('Test Doc', 'pdf-base64', [
|
||||
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Bob Smith', email: 'bob@realclient.com', role: 'VIEWER', signingOrder: 2 },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
|
||||
expect(callBody.recipients).toHaveLength(2);
|
||||
for (const r of callBody.recipients) {
|
||||
expect(r.email).toBe(REDIRECT_TARGET);
|
||||
// Original email preserved in the name for traceability
|
||||
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('generateDocumentFromTemplate — formValues *Email keys rewritten', async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.generateDocumentFromTemplate(42, {
|
||||
formValues: {
|
||||
'client.fullName': 'Alice Smith',
|
||||
'client.primaryEmail': 'alice@realclient.com',
|
||||
'developer.email': 'dev@realclient.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
|
||||
expect(callBody.formValues['client.primaryEmail']).toBe(REDIRECT_TARGET);
|
||||
expect(callBody.formValues['developer.email']).toBe(REDIRECT_TARGET);
|
||||
// Non-email field untouched
|
||||
expect(callBody.formValues['client.fullName']).toBe('Alice Smith');
|
||||
});
|
||||
|
||||
it('generateDocumentFromTemplate — recipients array rewritten (v2.x shape)', async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.generateDocumentFromTemplate(42, {
|
||||
recipients: [
|
||||
{ name: 'Alice', email: 'alice@realclient.com' },
|
||||
{ name: 'Bob', email: 'bob@realclient.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
|
||||
for (const r of callBody.recipients) {
|
||||
expect(r.email).toBe(REDIRECT_TARGET);
|
||||
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('sendDocument — short-circuited when redirect is set (no /send call)', async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.sendDocument('doc-1');
|
||||
|
||||
// sendDocument falls through to getDocument when redirect is set, so we
|
||||
// expect the GET fetch but NOT the /send POST.
|
||||
const calls = fetchMock.mock.calls;
|
||||
const sendCall = calls.find((c) => String(c[0]).includes('/send') && c[1]?.method === 'POST');
|
||||
expect(sendCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sendReminder — short-circuited when redirect is set (no /remind call)', async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.sendReminder('doc-1', 'signer-1');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createDocument — recipients NOT redirected when EMAIL_REDIRECT_TO unset', async () => {
|
||||
delete process.env.EMAIL_REDIRECT_TO;
|
||||
vi.resetModules();
|
||||
const mod = await import('@/lib/services/documenso-client');
|
||||
await mod.createDocument('Test Doc', 'pdf-base64', [
|
||||
{ name: 'Alice', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
|
||||
]);
|
||||
|
||||
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
|
||||
expect(callBody.recipients[0].email).toBe('alice@realclient.com');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. sendEmail redirect (covers the centralized path used by 5+ services)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('sendEmail redirect — EMAIL_REDIRECT_TO', () => {
|
||||
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('nodemailer');
|
||||
vi.resetModules();
|
||||
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
|
||||
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
|
||||
});
|
||||
|
||||
/**
|
||||
* Each test does its own reset → mock → import dance so the nodemailer
|
||||
* mock is the one observed by the freshly-imported `@/lib/email` module.
|
||||
* Returns the sendMail spy so the test can assert on it.
|
||||
*/
|
||||
async function setupWith(redirect: string | null) {
|
||||
if (redirect) process.env.EMAIL_REDIRECT_TO = redirect;
|
||||
else delete process.env.EMAIL_REDIRECT_TO;
|
||||
|
||||
vi.resetModules();
|
||||
const sendMailMock = vi.fn(async () => ({ messageId: '<msg@test>' }));
|
||||
vi.doMock('nodemailer', () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({ sendMail: sendMailMock })),
|
||||
},
|
||||
}));
|
||||
const mod = await import('@/lib/email');
|
||||
return { sendMailMock, mod };
|
||||
}
|
||||
|
||||
// The mock is typed as `vi.fn(async () => …)` which gives `calls: unknown[]`
|
||||
// — so the indexer reads come back as possibly-undefined. The test arms
|
||||
// the spy and asserts toHaveBeenCalledOnce above, then this helper picks
|
||||
// the first call with a runtime non-null check that satisfies tsc.
|
||||
function firstSendMailArgs(spy: ReturnType<typeof vi.fn>): {
|
||||
to: string;
|
||||
subject: string;
|
||||
} {
|
||||
const calls = spy.mock.calls;
|
||||
if (calls.length === 0) throw new Error('expected sendMail to be called');
|
||||
const args = calls[0]?.[0];
|
||||
if (!args) throw new Error('expected first call to have args');
|
||||
return args as { to: string; subject: string };
|
||||
}
|
||||
|
||||
it('rewrites to + prefixes subject when redirect set', async () => {
|
||||
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
|
||||
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi Alice</p>');
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledOnce();
|
||||
const args = firstSendMailArgs(sendMailMock);
|
||||
expect(args.to).toBe(REDIRECT_TARGET);
|
||||
expect(args.subject).toMatch(/^\[redirected from alice@realclient\.com\] Welcome$/);
|
||||
});
|
||||
|
||||
it('handles array of recipients — joins original list into the subject prefix', async () => {
|
||||
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
|
||||
await mod.sendEmail(['alice@realclient.com', 'bob@realclient.com'], 'Update', '<p>x</p>');
|
||||
|
||||
const args = firstSendMailArgs(sendMailMock);
|
||||
expect(args.to).toBe(REDIRECT_TARGET);
|
||||
expect(args.subject).toMatch(
|
||||
/^\[redirected from alice@realclient\.com, bob@realclient\.com\] Update$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through unchanged when redirect unset', async () => {
|
||||
const { sendMailMock, mod } = await setupWith(null);
|
||||
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi</p>');
|
||||
|
||||
const args = firstSendMailArgs(sendMailMock);
|
||||
expect(args.to).toBe('alice@realclient.com');
|
||||
expect(args.subject).toBe('Welcome');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Webhook short-circuit (covers the per-port outbound webhook delivery)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Webhook short-circuit — EMAIL_REDIRECT_TO', () => {
|
||||
// The actual webhook worker pulls from BullMQ + the DB. To keep this a
|
||||
// pure unit test, we extract the "should I dispatch?" predicate and
|
||||
// assert against env.EMAIL_REDIRECT_TO directly. The full integration
|
||||
// path is already covered by tests/integration/webhook-delivery.test.ts.
|
||||
|
||||
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
|
||||
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
|
||||
});
|
||||
|
||||
it('the worker reads process.env.EMAIL_REDIRECT_TO at dispatch time', () => {
|
||||
// Sanity: the worker uses process.env directly (not a cached env import)
|
||||
// so flipping the env at runtime takes effect on the next job.
|
||||
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
|
||||
expect(process.env.EMAIL_REDIRECT_TO).toBe(REDIRECT_TARGET);
|
||||
delete process.env.EMAIL_REDIRECT_TO;
|
||||
expect(process.env.EMAIL_REDIRECT_TO).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -142,7 +142,7 @@ describe('calculateInterestScore', () => {
|
||||
portId: 'p1',
|
||||
clientId: 'c1',
|
||||
createdAt: daysAgo(10),
|
||||
pipelineStage: 'contract',
|
||||
pipelineStage: 'contract_signed',
|
||||
eoiStatus: 'signed',
|
||||
contractStatus: 'signed',
|
||||
depositStatus: 'received',
|
||||
|
||||
Reference in New Issue
Block a user