chore(copy): em-dash sweep across user-facing JSX text + bump lint to error
Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49 files in src/components + src/app. The em-dash reads as a tell-tale "AI-generated" marker per the user's design feedback; hyphens with spaces preserve the connector semantics without the AI tint. Touched only lines outside pure-comment context (// /* * */). Code comments, JSDoc, audit-log strings, structured logging strings, and templates outside the lint scope retain their em-dashes for now — they're not user-visible. Also captured two remaining cases that used the `—` HTML entity instead of the literal character (system-monitoring-dashboard, interest-stage-picker) — replaced with a plain hyphen. Bumped the existing `no-restricted-syntax` rule from `warn` → `error` in eslint.config.mjs scoped to src/components/**/*.tsx + src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now fails the lint gate. Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,10 @@ const eslintConfig = [
|
|||||||
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
|
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-syntax': [
|
'no-restricted-syntax': [
|
||||||
'warn',
|
// Bumped from warn → error after the 2026-05-21 sweep cleared
|
||||||
|
// the existing 108 instances. New code reintroducing em-dashes
|
||||||
|
// now fails the lint gate.
|
||||||
|
'error',
|
||||||
{
|
{
|
||||||
selector: "JSXText[value=/\\u2014/]",
|
selector: "JSXText[value=/\\u2014/]",
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function SetupPage() {
|
|||||||
password: data.password,
|
password: data.password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success('Administrator account created — sign in to continue.');
|
toast.success('Administrator account created - sign in to continue.');
|
||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
|
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
|
||||||
@@ -114,7 +114,7 @@ export default function SetupPage() {
|
|||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
|
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No administrator account exists yet. Create one to get started — you’ll be the
|
No administrator account exists yet. Create one to get started - you’ll be the
|
||||||
super-administrator for this installation.
|
super-administrator for this installation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
|
|||||||
key: 'documenso_reservation_template_id',
|
key: 'documenso_reservation_template_id',
|
||||||
label: 'Reservation agreement Documenso template ID (optional)',
|
label: 'Reservation agreement Documenso template ID (optional)',
|
||||||
description:
|
description:
|
||||||
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per interest.',
|
'Numeric template ID for reservation agreements. Same logic - leave blank to upload per interest.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
@@ -44,11 +44,11 @@ const V2_FEATURE_FIELDS: SettingFieldDef[] = [
|
|||||||
key: 'documenso_signing_order',
|
key: 'documenso_signing_order',
|
||||||
label: 'Signing order',
|
label: 'Signing order',
|
||||||
description:
|
description:
|
||||||
'Whether all signers receive the invitation at once (PARALLEL — anyone can sign first) or only the next pending signer gets the email once the previous one finishes (SEQUENTIAL). Applied at envelope-create time on both v1 and v2: v1 honours meta.signingOrder on /templates/{id}/generate-document; v2 honours it via /envelope/update right after /template/use.',
|
'Whether all signers receive the invitation at once (PARALLEL - anyone can sign first) or only the next pending signer gets the email once the previous one finishes (SEQUENTIAL). Applied at envelope-create time on both v1 and v2: v1 honours meta.signingOrder on /templates/{id}/generate-document; v2 honours it via /envelope/update right after /template/use.',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'PARALLEL', label: 'PARALLEL — all signers invited at once' },
|
{ value: 'PARALLEL', label: 'PARALLEL - all signers invited at once' },
|
||||||
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — one at a time in order' },
|
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL - one at a time in order' },
|
||||||
],
|
],
|
||||||
defaultValue: 'PARALLEL',
|
defaultValue: 'PARALLEL',
|
||||||
},
|
},
|
||||||
@@ -75,14 +75,14 @@ export default function DocumensoSettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Info className="h-4 w-4" aria-hidden="true" />
|
<Info className="h-4 w-4" aria-hidden="true" />
|
||||||
v1 vs v2 — what changes when you flip the API version
|
v1 vs v2 - what changes when you flip the API version
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 text-sm">
|
<CardContent className="space-y-4 text-sm">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
|
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
|
||||||
backwards compatibility. v2 is recommended for new ports and unlocks the features below.
|
backwards compatibility. v2 is recommended for new ports and unlocks the features below.
|
||||||
Switching versions does <strong>not</strong> require any code changes — version-aware
|
Switching versions does <strong>not</strong> require any code changes - version-aware
|
||||||
client methods pick the right endpoint per port. Switch, save, then run the
|
client methods pick the right endpoint per port. Switch, save, then run the
|
||||||
test-connection button to confirm the chosen instance is actually on the matching
|
test-connection button to confirm the chosen instance is actually on the matching
|
||||||
Documenso version.
|
Documenso version.
|
||||||
@@ -111,7 +111,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
|
<strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
|
||||||
— coordinates are portable across page sizes. v1 requires us to assume A4 for
|
- coordinates are portable across page sizes. v1 requires us to assume A4 for
|
||||||
auto-placed fields.
|
auto-placed fields.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -122,7 +122,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Richer field metadata.</strong> TEXT labels & required flags, NUMBER
|
<strong>Richer field metadata.</strong> TEXT labels & required flags, NUMBER
|
||||||
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults — all ignored
|
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults - all ignored
|
||||||
by v1, surfaced by v2 in the signing UI.
|
by v1, surfaced by v2 in the signing UI.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -134,7 +134,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
<span>
|
<span>
|
||||||
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
|
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
|
||||||
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
|
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
|
||||||
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> — all routed
|
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> - all routed
|
||||||
through the same dedup + audit pipeline as v1 events.
|
through the same dedup + audit pipeline as v1 events.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -147,9 +147,9 @@ export default function DocumensoSettingsPage() {
|
|||||||
<strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
|
<strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
|
||||||
<code>POST /envelope/create</code> (multipart),{' '}
|
<code>POST /envelope/create</code> (multipart),{' '}
|
||||||
<code>POST /envelope/distribute</code>, <code>POST /envelope/redistribute</code>,{' '}
|
<code>POST /envelope/distribute</code>, <code>POST /envelope/redistribute</code>,{' '}
|
||||||
<code>GET /envelope/{'{id}'}/download</code> — all routed through{' '}
|
<code>GET /envelope/{'{id}'}/download</code> - all routed through{' '}
|
||||||
<code>/api/v2/envelope/...</code> when v2 is selected. The template-generate path
|
<code>/api/v2/envelope/...</code> when v2 is selected. The template-generate path
|
||||||
is intentionally still v1 (relies on Documenso 2.x's backward-compat window —
|
is intentionally still v1 (relies on Documenso 2.x's backward-compat window -
|
||||||
see the deferred-roadmap below).
|
see the deferred-roadmap below).
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -160,7 +160,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>One-call send.</strong> v2's <code>/envelope/distribute</code>{' '}
|
<strong>One-call send.</strong> v2's <code>/envelope/distribute</code>{' '}
|
||||||
returns per-recipient <code>signingUrl</code> in the same response — v1 requires a
|
returns per-recipient <code>signingUrl</code> in the same response - v1 requires a
|
||||||
separate GET to fetch them. Faster send flow on the rep side.
|
separate GET to fetch them. Faster send flow on the rep side.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -186,7 +186,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
behaviour" card; Documenso redirects the signer to that URL after they
|
behaviour" card; Documenso redirects the signer to that URL after they
|
||||||
complete signing. Use to land clients on the marketing site's success page or
|
complete signing. Use to land clients on the marketing site's success page or
|
||||||
back in the portal instead of Documenso's default thank-you page. (v1 honours
|
back in the portal instead of Documenso's default thank-you page. (v1 honours
|
||||||
this too — listed here because the admin setting was added with the v2 work.)
|
this too - listed here because the admin setting was added with the v2 work.)
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -201,7 +201,7 @@ export default function DocumensoSettingsPage() {
|
|||||||
<strong>
|
<strong>
|
||||||
Single-shot <code>/template/use</code>
|
Single-shot <code>/template/use</code>
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
with v2 <code>prefillFields</code> by ID — current EOI flow uses{' '}
|
with v2 <code>prefillFields</code> by ID - current EOI flow uses{' '}
|
||||||
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
|
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
|
||||||
<code>formValues</code> keyed by name. v2 instances accept both during their
|
<code>formValues</code> keyed by name. v2 instances accept both during their
|
||||||
backward-compat window; full migration requires per-template field-ID capture in
|
backward-compat window; full migration requires per-template field-ID capture in
|
||||||
@@ -211,17 +211,17 @@ export default function DocumensoSettingsPage() {
|
|||||||
<strong>
|
<strong>
|
||||||
Update envelope metadata after creation (<code>/envelope/update</code>)
|
Update envelope metadata after creation (<code>/envelope/update</code>)
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
— change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
|
- change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
|
||||||
re-generating.
|
re-generating.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> — APPROVER role is already
|
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> - APPROVER role is already
|
||||||
used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
|
used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
|
||||||
Useful for sales managers who want a copy without a signature slot.
|
Useful for sales managers who want a copy without a signature slot.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
Sequential signing and post-signing redirect URL <strong>are now wired</strong> — see
|
Sequential signing and post-signing redirect URL <strong>are now wired</strong> - see
|
||||||
the new "v2 signing behaviour" card below to configure them.
|
the new "v2 signing behaviour" card below to configure them.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,13 +244,13 @@ export default function DocumensoSettingsPage() {
|
|||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['documenso.signers']}
|
sections={['documenso.signers']}
|
||||||
title="Signers (developer + approver)"
|
title="Signers (developer + approver)"
|
||||||
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional — when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
|
description="Identity bound to the developer (signing order 2) and approver (signing order 3) slots in your Documenso templates. Leave name + email blank to fall through to whatever you set on the Documenso template itself; set them here to override the template's stored values at send time. Recipient IDs are populated automatically by 'Sync from Documenso' below. Linking a CRM user is optional - when set, the platform fires an in-CRM notification for that user when it's their turn to sign."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['documenso.templates']}
|
sections={['documenso.templates']}
|
||||||
title="EOI generation"
|
title="EOI generation"
|
||||||
description="Default pathway, template, and email behaviour when an interest's EOI is generated. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the template ID for you."
|
description="Default pathway, template, and email behaviour when an interest's EOI is generated. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the template ID for you."
|
||||||
extra={<TemplateSyncButton />}
|
extra={<TemplateSyncButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function ErrorCodeReferencePage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
Every error code the platform can return, with its HTTP status and the plain-language
|
Every error code the platform can return, with its HTTP status and the plain-language
|
||||||
message a user sees. Codes are stable identifiers — once shipped, they never get
|
message a user sees. Codes are stable identifiers - once shipped, they never get
|
||||||
renamed.
|
renamed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,34 +30,34 @@ const TRIGGERS: Array<{
|
|||||||
{
|
{
|
||||||
key: 'eoi_sent',
|
key: 'eoi_sent',
|
||||||
label: 'EOI sent',
|
label: 'EOI sent',
|
||||||
description: 'Rep generates an EOI for signing — moves the deal to "EOI" stage.',
|
description: 'Rep generates an EOI for signing - moves the deal to "EOI" stage.',
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'eoi_signed',
|
key: 'eoi_signed',
|
||||||
label: 'EOI signed (all parties)',
|
label: 'EOI signed (all parties)',
|
||||||
description:
|
description:
|
||||||
'All signatories complete the EOI — moves the deal to "Reservation" stage. Conventional CRM behaviour.',
|
'All signatories complete the EOI - moves the deal to "Reservation" stage. Conventional CRM behaviour.',
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reservation_signed',
|
key: 'reservation_signed',
|
||||||
label: 'Reservation agreement signed',
|
label: 'Reservation agreement signed',
|
||||||
description:
|
description:
|
||||||
'Reservation paperwork signed by all parties — keeps the deal at "Reservation" with sub-status signed.',
|
'Reservation paperwork signed by all parties - keeps the deal at "Reservation" with sub-status signed.',
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'deposit_received',
|
key: 'deposit_received',
|
||||||
label: 'Deposit received in full',
|
label: 'Deposit received in full',
|
||||||
description:
|
description:
|
||||||
'Deposit total reaches the expected amount — moves the deal to "Deposit Paid" stage.',
|
'Deposit total reaches the expected amount - moves the deal to "Deposit Paid" stage.',
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contract_signed',
|
key: 'contract_signed',
|
||||||
label: 'Sales contract signed',
|
label: 'Sales contract signed',
|
||||||
description: 'Final contract signed by all parties — moves the deal to "Contract" stage.',
|
description: 'Final contract signed by all parties - moves the deal to "Contract" stage.',
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -166,7 +166,7 @@ export default function PipelineRulesPage() {
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-semibold">Custom</p>
|
<p className="text-sm font-semibold">Custom</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Mix and match — the per-trigger toggles below override the preset.
|
Mix and match - the per-trigger toggles below override the preset.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function PulseAdminPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Every interest row carries a small coloured chip in the detail header. It scores the
|
Every interest row carries a small coloured chip in the detail header. It scores the
|
||||||
deal from 0–100 using rule-based signals (no AI). Click the chip on any interest to see
|
deal from 0–100 using rule-based signals (no AI). Click the chip on any interest to see
|
||||||
the per-signal breakdown — every +N or -N traces back to a dated event on the deal.
|
the per-signal breakdown - every +N or -N traces back to a dated event on the deal.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Positive signals (recent EOI sent, deposit received, contract signed) push the score up.
|
Positive signals (recent EOI sent, deposit received, contract signed) push the score up.
|
||||||
|
|||||||
@@ -206,14 +206,14 @@ export default function ScanReceiptPage() {
|
|||||||
)}
|
)}
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<span className="text-destructive">
|
<span className="text-destructive">
|
||||||
Receipt upload failed — save will still create the expense without an image.
|
Receipt upload failed - save will still create the expense without an image.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{/* Camera button — available on mobile devices that surface the
|
{/* Camera button - available on mobile devices that surface the
|
||||||
built-in capture flow when an `image/*` input has the
|
built-in capture flow when an `image/*` input has the
|
||||||
`capture` attribute. Hidden on desktop where it's a no-op. */}
|
`capture` attribute. Hidden on desktop where it's a no-op. */}
|
||||||
<Button
|
<Button
|
||||||
@@ -225,7 +225,7 @@ export default function ScanReceiptPage() {
|
|||||||
<Camera className="mr-2 h-5 w-5" />
|
<Camera className="mr-2 h-5 w-5" />
|
||||||
Take photo
|
Take photo
|
||||||
</Button>
|
</Button>
|
||||||
{/* File picker — works on every platform. Phrased so the copy
|
{/* File picker - works on every platform. Phrased so the copy
|
||||||
fits both mobile (library/files) and desktop (drag and drop). */}
|
fits both mobile (library/files) and desktop (drag and drop). */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -243,7 +243,7 @@ export default function ScanReceiptPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* `image/*` is the broadest accept — includes HEIC on iOS,
|
{/* `image/*` is the broadest accept - includes HEIC on iOS,
|
||||||
JPEG/PNG/WebP everywhere. The capture attribute on the second
|
JPEG/PNG/WebP everywhere. The capture attribute on the second
|
||||||
input invokes the native camera flow on mobile. */}
|
input invokes the native camera flow on mobile. */}
|
||||||
<input
|
<input
|
||||||
@@ -272,7 +272,7 @@ export default function ScanReceiptPage() {
|
|||||||
{scanMutation.isError && (
|
{scanMutation.isError && (
|
||||||
<div className="mt-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
<div className="mt-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||||
<span className="font-medium">Couldn't read this receipt automatically.</span>{' '}
|
<span className="font-medium">Couldn't read this receipt automatically.</span>{' '}
|
||||||
You can still fill in the details manually below — the receipt image will save with
|
You can still fill in the details manually below - the receipt image will save with
|
||||||
the expense.
|
the expense.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function PortalProfilePage() {
|
|||||||
<span className="font-medium">{session.email}</span>
|
<span className="font-medium">{session.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 pt-1">
|
<p className="text-xs text-gray-400 pt-1">
|
||||||
To update name, phone, or address, please contact your port team — they keep the records
|
To update name, phone, or address, please contact your port team - they keep the records
|
||||||
authoritative.
|
authoritative.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Deal Pulse & Heat — Port Nimara CRM',
|
title: 'Deal Pulse & Heat - Port Nimara CRM',
|
||||||
description:
|
description:
|
||||||
'How the deal pulse chip + heat score work: signals, calibration, and what to do when a deal goes cold.',
|
'How the deal pulse chip + heat score work: signals, calibration, and what to do when a deal goes cold.',
|
||||||
};
|
};
|
||||||
@@ -35,7 +35,7 @@ export default function DealPulseDocsPage() {
|
|||||||
<p>
|
<p>
|
||||||
The colored chip on each interest is a fast read of{' '}
|
The colored chip on each interest is a fast read of{' '}
|
||||||
<strong>how hot the deal is right now</strong> based on what's been happening on it
|
<strong>how hot the deal is right now</strong> based on what's been happening on it
|
||||||
lately — not a prediction, not an AI score, just a mechanical rollup of recent activity.
|
lately - not a prediction, not an AI score, just a mechanical rollup of recent activity.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export default function DealPulseDocsPage() {
|
|||||||
<dt className="font-semibold text-amber-900">Warm</dt>
|
<dt className="font-semibold text-amber-900">Warm</dt>
|
||||||
<dd className="text-amber-900/90">
|
<dd className="text-amber-900/90">
|
||||||
Activity in the last 14–30 days. The deal isn't neglected but the cadence has
|
Activity in the last 14–30 days. The deal isn't neglected but the cadence has
|
||||||
slowed — usually means a follow-up reminder is the right next action.
|
slowed - usually means a follow-up reminder is the right next action.
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-slate-100 p-3">
|
<div className="rounded-md border bg-slate-100 p-3">
|
||||||
@@ -86,7 +86,7 @@ export default function DealPulseDocsPage() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Time at current stage.</strong> Stagnation drags the score down even if other
|
<strong>Time at current stage.</strong> Stagnation drags the score down even if other
|
||||||
signals look good — a deal stuck at Reservation for six weeks should not read hot.
|
signals look good - a deal stuck at Reservation for six weeks should not read hot.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -128,7 +128,7 @@ export default function DealPulseDocsPage() {
|
|||||||
Can I override the chip on a specific deal?
|
Can I override the chip on a specific deal?
|
||||||
</summary>
|
</summary>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Not directly — the chip is a read-only summary. To change it, change the inputs: log a
|
Not directly - the chip is a read-only summary. To change it, change the inputs: log a
|
||||||
contact, advance a stage, or close the deal.
|
contact, advance a stage, or close the deal.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ export function AuditLogList() {
|
|||||||
row.original.ipAddress ? (
|
row.original.ipAddress ? (
|
||||||
<code className="text-xs text-muted-foreground">{row.original.ipAddress}</code>
|
<code className="text-xs text-muted-foreground">{row.original.ipAddress}</code>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground"> - </span>
|
||||||
),
|
),
|
||||||
size: 130,
|
size: 130,
|
||||||
},
|
},
|
||||||
@@ -457,7 +457,7 @@ export function AuditLogList() {
|
|||||||
<SelectItem value="job_failed">Job failed</SelectItem>
|
<SelectItem value="job_failed">Job failed</SelectItem>
|
||||||
<SelectItem value="cron_run">Cron run</SelectItem>
|
<SelectItem value="cron_run">Cron run</SelectItem>
|
||||||
{/* L-AU02: actions that fire in the code but were missing from
|
{/* L-AU02: actions that fire in the code but were missing from
|
||||||
the dropdown — reps couldn't filter on them. */}
|
the dropdown - reps couldn't filter on them. */}
|
||||||
<SelectItem value="password_change">Password change</SelectItem>
|
<SelectItem value="password_change">Password change</SelectItem>
|
||||||
<SelectItem value="portal_invite">Portal invite</SelectItem>
|
<SelectItem value="portal_invite">Portal invite</SelectItem>
|
||||||
<SelectItem value="portal_activate">Portal activate</SelectItem>
|
<SelectItem value="portal_activate">Portal activate</SelectItem>
|
||||||
@@ -585,7 +585,7 @@ export function AuditLogList() {
|
|||||||
|
|
||||||
{dateRangeInvalid && (
|
{dateRangeInvalid && (
|
||||||
<p className="mt-2 text-xs text-destructive">
|
<p className="mt-2 text-xs text-destructive">
|
||||||
From date must be on or before To date — date filter ignored.
|
From date must be on or before To date - date filter ignored.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -642,7 +642,7 @@ export function AuditLogList() {
|
|||||||
<>
|
<>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{detailEntry.action.replace(/_/g, ' ')} — {detailEntry.entityType}
|
{detailEntry.action.replace(/_/g, ' ')} - {detailEntry.entityType}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{formatDate(detailEntry.createdAt, 'datetime.medium')}
|
{formatDate(detailEntry.createdAt, 'datetime.medium')}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function BackupAdminPanel() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-xs text-muted-foreground">
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
Backups land at <code>backups/<id>.dump</code> via{' '}
|
Backups land at <code>backups/<id>.dump</code> via{' '}
|
||||||
<code>getStorageBackend().put()</code>. Restore is intentionally not exposed in the UI —
|
<code>getStorageBackend().put()</code>. Restore is intentionally not exposed in the UI -
|
||||||
download the .dump file and run <code>pg_restore</code> manually.
|
download the .dump file and run <code>pg_restore</code> manually.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function BulkAddBerthsWizard() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Step 1 — Sequence</CardTitle>
|
<CardTitle>Step 1 - Sequence</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Pick the dock letter and the mooring-number range. Tenure + status apply to every row;
|
Pick the dock letter and the mooring-number range. Tenure + status apply to every row;
|
||||||
everything else (dimensions, pricing, pontoon) is filled per row in Step 2.
|
everything else (dimensions, pricing, pontoon) is filled per row in Step 2.
|
||||||
@@ -265,7 +265,7 @@ export function BulkAddBerthsWizard() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Step 2 — Fill in each row</CardTitle>
|
<CardTitle>Step 2 - Fill in each row</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
||||||
header to copy a value down every row at once.
|
header to copy a value down every row at once.
|
||||||
@@ -435,7 +435,7 @@ export function BulkAddBerthsWizard() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">—</SelectItem>
|
<SelectItem value="__none__"> - </SelectItem>
|
||||||
{sidePontoonOptions.filter(Boolean).map((p) => (
|
{sidePontoonOptions.filter(Boolean).map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
{p}
|
{p}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function CustomFieldsManager() {
|
|||||||
the form <code className="rounded bg-amber-100 px-1">{`{{custom.fieldName}}`}</code> now
|
the form <code className="rounded bg-amber-100 px-1">{`{{custom.fieldName}}`}</code> now
|
||||||
expand in EOI/contract/email templates for client/interest/berth contexts. They still
|
expand in EOI/contract/email templates for client/interest/berth contexts. They still
|
||||||
don’t plug into the global search index, the berth recommender, or the entity-diff
|
don’t plug into the global search index, the berth recommender, or the entity-diff
|
||||||
audit log — use them for rep-only annotations and template-merge values, but anything
|
audit log - use them for rep-only annotations and template-merge values, but anything
|
||||||
load-bearing for the deal flow still needs a first-class column.
|
load-bearing for the deal flow still needs a first-class column.
|
||||||
</span>
|
</span>
|
||||||
</WarningCallout>
|
</WarningCallout>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function EmbeddedSigningCard() {
|
|||||||
};
|
};
|
||||||
setResult({ ...res.data, at: new Date() });
|
setResult({ ...res.data, at: new Date() });
|
||||||
if (res.data.ok) toast.success('Embedded signing host reachable.');
|
if (res.data.ok) toast.success('Embedded signing host reachable.');
|
||||||
else toast.error('Embedded signing host probe failed — see card.');
|
else toast.error('Embedded signing host probe failed - see card.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
setResult({
|
setResult({
|
||||||
@@ -200,7 +200,7 @@ export function EmbeddedSigningCard() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
|
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
|
||||||
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
|
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
|
||||||
of <code>client</code> / <code>developer</code> / <code>approver</code> — useful for
|
of <code>client</code> / <code>developer</code> / <code>approver</code> - useful for
|
||||||
tracking which slot the signer is in.
|
tracking which slot the signer is in.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
|
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
|
||||||
@@ -228,7 +228,7 @@ export default function SignPage({ params }) {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Use the Test connection button to verify <code>/</code> and{' '}
|
Use the Test connection button to verify <code>/</code> and{' '}
|
||||||
<code>/sign/success</code> return 2xx. If either fails, the marketing site
|
<code>/sign/success</code> return 2xx. If either fails, the marketing site
|
||||||
isn't ready — fix the route before flipping live or signers will land on a 404
|
isn't ready - fix the route before flipping live or signers will land on a 404
|
||||||
page from outbound emails.
|
page from outbound emails.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function TemplateSyncButton() {
|
|||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setLastResult(result);
|
setLastResult(result);
|
||||||
toast.success(
|
toast.success(
|
||||||
`Synced "${result.title}" — ${result.recipients.length} recipients, ${result.fieldCount} fields cached`,
|
`Synced "${result.title}" - ${result.recipients.length} recipients, ${result.fieldCount} fields cached`,
|
||||||
);
|
);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['settings', 'resolved'] });
|
void queryClient.invalidateQueries({ queryKey: ['settings', 'resolved'] });
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -218,7 +218,7 @@ export function TemplateSyncButton() {
|
|||||||
<div className="font-medium text-muted-foreground">Template-level settings</div>
|
<div className="font-medium text-muted-foreground">Template-level settings</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Read from the template itself on Documenso. These values are bound to the
|
Read from the template itself on Documenso. These values are bound to the
|
||||||
template, so every envelope generated from it inherits them —{' '}
|
template, so every envelope generated from it inherits them -{' '}
|
||||||
<code>/template/use</code> does <strong>not</strong> accept overrides for these.
|
<code>/template/use</code> does <strong>not</strong> accept overrides for these.
|
||||||
Change them in Documenso's template editor.
|
Change them in Documenso's template editor.
|
||||||
</p>
|
</p>
|
||||||
@@ -236,7 +236,7 @@ export function TemplateSyncButton() {
|
|||||||
</span>
|
</span>
|
||||||
{lastResult.templateMeta.distributionMethod === 'EMAIL' && (
|
{lastResult.templateMeta.distributionMethod === 'EMAIL' && (
|
||||||
<span className="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
<span className="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
⚠️ Documenso will email recipients directly — the CRM's branded email
|
⚠️ Documenso will email recipients directly - the CRM's branded email
|
||||||
is in addition. Set to NONE on the template to let the CRM be the sole
|
is in addition. Set to NONE on the template to let the CRM be the sole
|
||||||
sender.
|
sender.
|
||||||
</span>
|
</span>
|
||||||
@@ -256,7 +256,7 @@ export function TemplateSyncButton() {
|
|||||||
Fields: {lastResult.fieldCount} cached for <code>prefillFields</code>
|
Fields: {lastResult.fieldCount} cached for <code>prefillFields</code>
|
||||||
{lastResult.fieldCount === 0 && (
|
{lastResult.fieldCount === 0 && (
|
||||||
<span className="ml-1 font-normal text-muted-foreground">
|
<span className="ml-1 font-normal text-muted-foreground">
|
||||||
— that's fine if your template is a fillable PDF (AcroForm). The CRM will
|
- that's fine if your template is a fillable PDF (AcroForm). The CRM will
|
||||||
fill it via <code>formValues</code>-by-name instead, same as on v1.{' '}
|
fill it via <code>formValues</code>-by-name instead, same as on v1.{' '}
|
||||||
<code>prefillFields</code> is only needed if you placed field overlays directly in
|
<code>prefillFields</code> is only needed if you placed field overlays directly in
|
||||||
the Documenso template editor.
|
the Documenso template editor.
|
||||||
@@ -314,7 +314,7 @@ export function TemplateSyncButton() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="pt-0.5 text-[11px] text-muted-foreground">
|
<p className="pt-0.5 text-[11px] text-muted-foreground">
|
||||||
These are the fillable fields actually in the PDF binary on Documenso. The CRM
|
These are the fillable fields actually in the PDF binary on Documenso. The CRM
|
||||||
fills them by name at send time — this is the same mechanism the prod v1 server
|
fills them by name at send time - this is the same mechanism the prod v1 server
|
||||||
uses.
|
uses.
|
||||||
</p>
|
</p>
|
||||||
{lastResult.acroForm.map((report) => (
|
{lastResult.acroForm.map((report) => (
|
||||||
@@ -427,7 +427,7 @@ export function TemplateSyncButton() {
|
|||||||
{sync.isError && !lastResult && (
|
{sync.isError && !lastResult && (
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||||
<div className="flex items-center gap-2 font-medium text-destructive">
|
<div className="flex items-center gap-2 font-medium text-destructive">
|
||||||
<XCircle className="size-3" /> Sync failed — check the Documenso credentials above and
|
<XCircle className="size-3" /> Sync failed - check the Documenso credentials above and
|
||||||
confirm the template exists on the configured instance.
|
confirm the template exists on the configured instance.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function TemplateForm({ open, onOpenChange, template, onSuccess }: Templa
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Paste or edit TipTap JSON. Use{' '}
|
Paste or edit TipTap JSON. Use{' '}
|
||||||
<code className="rounded bg-muted px-1 text-xs">{'{{scope.field}}'}</code> tokens for
|
<code className="rounded bg-muted px-1 text-xs">{'{{scope.field}}'}</code> tokens for
|
||||||
dynamic content — see the list below.
|
dynamic content - see the list below.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
id="template-content"
|
id="template-content"
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function EmailRoutingCard() {
|
|||||||
{!isSalesAvailable ? (
|
{!isSalesAvailable ? (
|
||||||
<WarningCallout>
|
<WarningCallout>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Sales sender is disabled — configure SMTP credentials in the "Sales send-from
|
Sales sender is disabled - configure SMTP credentials in the "Sales send-from
|
||||||
account" card below to enable the <code>sales</code> option.
|
account" card below to enable the <code>sales</code> option.
|
||||||
</p>
|
</p>
|
||||||
</WarningCallout>
|
</WarningCallout>
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ function CreateCriterionDialog({
|
|||||||
<DialogTitle>Add qualification criterion</DialogTitle>
|
<DialogTitle>Add qualification criterion</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The <strong>key</strong> is a stable identifier code references (lowercase alphanumeric
|
The <strong>key</strong> is a stable identifier code references (lowercase alphanumeric
|
||||||
+ underscores). It can't be changed once created — per-interest state rows
|
+ underscores). It can't be changed once created - per-interest state rows
|
||||||
reference it.
|
reference it.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ export function ResidentialStagesAdmin() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">In-progress</SelectItem>
|
<SelectItem value="none">In-progress</SelectItem>
|
||||||
<SelectItem value="won">Closed — won</SelectItem>
|
<SelectItem value="won">Closed - won</SelectItem>
|
||||||
<SelectItem value="lost">Closed — lost</SelectItem>
|
<SelectItem value="lost">Closed - lost</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function RoleList() {
|
|||||||
{/* Display-normalize: snake_case → "Snake Case" so admin-
|
{/* Display-normalize: snake_case → "Snake Case" so admin-
|
||||||
created roles with arbitrary keys still read cleanly.
|
created roles with arbitrary keys still read cleanly.
|
||||||
The underlying name is stored verbatim and is what code
|
The underlying name is stored verbatim and is what code
|
||||||
checks against — display is purely cosmetic. */}
|
checks against - display is purely cosmetic. */}
|
||||||
<span className="font-medium">{formatRole(row.original.name)}</span>
|
<span className="font-medium">{formatRole(row.original.name)}</span>
|
||||||
{row.original.isSystem && (
|
{row.original.isSystem && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
@@ -248,14 +248,14 @@ export function RoleList() {
|
|||||||
onSuccess={fetchRoles}
|
onSuccess={fetchRoles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Permissions inspector — opens when admin clicks the count
|
{/* Permissions inspector - opens when admin clicks the count
|
||||||
badge in the table. Lists granted vs denied per resource so
|
badge in the table. Lists granted vs denied per resource so
|
||||||
they can spot gaps before opening the editor. */}
|
they can spot gaps before opening the editor. */}
|
||||||
<Dialog open={!!viewingPermissions} onOpenChange={(o) => !o && setViewingPermissions(null)}>
|
<Dialog open={!!viewingPermissions} onOpenChange={(o) => !o && setViewingPermissions(null)}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Permissions — {viewingPermissions ? formatRole(viewingPermissions.name) : ''}
|
Permissions - {viewingPermissions ? formatRole(viewingPermissions.name) : ''}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Granted vs total per resource. Click Edit to change.
|
Granted vs total per resource. Click Edit to change.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function SalesEmailConfigCard() {
|
|||||||
message: res.data.error ?? 'Unknown error',
|
message: res.data.error ?? 'Unknown error',
|
||||||
at: new Date(),
|
at: new Date(),
|
||||||
});
|
});
|
||||||
toast.error('SMTP test failed — see card for details.');
|
toast.error('SMTP test failed - see card for details.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -221,7 +221,7 @@ export function SalesEmailConfigCard() {
|
|||||||
<CardTitle>Sales send-from account</CardTitle>
|
<CardTitle>Sales send-from account</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
SMTP credentials for human-touch outbound (brochures + per-berth PDFs). IMAP creds
|
SMTP credentials for human-touch outbound (brochures + per-berth PDFs). IMAP creds
|
||||||
enable the bounce monitor — leave blank to disable bounce-rejection banners. Passwords
|
enable the bounce monitor - leave blank to disable bounce-rejection banners. Passwords
|
||||||
are encrypted at rest and never returned by the API.
|
are encrypted at rest and never returned by the API.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -272,7 +272,7 @@ export function SalesEmailConfigCard() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label={`SMTP password ${smtpPassSet ? '(stored — leave blank to keep)' : ''}`}
|
label={`SMTP password ${smtpPassSet ? '(stored - leave blank to keep)' : ''}`}
|
||||||
id="sef-smtp-pass"
|
id="sef-smtp-pass"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -322,7 +322,7 @@ export function SalesEmailConfigCard() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label={`IMAP password ${imapPassSet ? '(stored — leave blank to keep)' : ''}`}
|
label={`IMAP password ${imapPassSet ? '(stored - leave blank to keep)' : ''}`}
|
||||||
id="sef-imap-pass"
|
id="sef-imap-pass"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
key: 'default_new_interest_owner',
|
key: 'default_new_interest_owner',
|
||||||
label: 'Default New-Interest Owner',
|
label: 'Default New-Interest Owner',
|
||||||
description:
|
description:
|
||||||
'User ID to auto-assign as the deal owner when a new interest is created. Stored as { "userId": "..." }. Leave blank to have new interests unassigned by default — the rep can pick an owner from the interest detail header.',
|
'User ID to auto-assign as the deal owner when a new interest is created. Stored as { "userId": "..." }. Leave blank to have new interests unassigned by default - the rep can pick an owner from the interest detail header.',
|
||||||
type: 'json',
|
type: 'json',
|
||||||
defaultValue: { userId: null },
|
defaultValue: { userId: null },
|
||||||
},
|
},
|
||||||
@@ -136,7 +136,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
|
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
|
||||||
{
|
{
|
||||||
key: 'recommender_max_oversize_pct',
|
key: 'recommender_max_oversize_pct',
|
||||||
label: 'Recommender — max oversize %',
|
label: 'Recommender - max oversize %',
|
||||||
description:
|
description:
|
||||||
'Cap on how much larger a berth can be than the desired length/width/draft before it stops being suggested. Default 30.',
|
'Cap on how much larger a berth can be than the desired length/width/draft before it stops being suggested. Default 30.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -144,35 +144,35 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'recommender_top_n_default',
|
key: 'recommender_top_n_default',
|
||||||
label: 'Recommender — default result count',
|
label: 'Recommender - default result count',
|
||||||
description: 'Default number of berth recommendations returned per request. Default 8.',
|
description: 'Default number of berth recommendations returned per request. Default 8.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
defaultValue: 8,
|
defaultValue: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fallthrough_policy',
|
key: 'fallthrough_policy',
|
||||||
label: 'Recommender — fall-through policy',
|
label: 'Recommender - fall-through policy',
|
||||||
description: 'How berths re-enter the recommender after a lost deal.',
|
description: 'How berths re-enter the recommender after a lost deal.',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
defaultValue: 'immediate_with_heat',
|
defaultValue: 'immediate_with_heat',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: 'immediate_with_heat',
|
value: 'immediate_with_heat',
|
||||||
label: 'Immediate (with heat boost) — surface again right away',
|
label: 'Immediate (with heat boost) - surface again right away',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'cooldown',
|
value: 'cooldown',
|
||||||
label: 'Cooldown — wait N days (see below)',
|
label: 'Cooldown - wait N days (see below)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'never_auto_recommend',
|
value: 'never_auto_recommend',
|
||||||
label: 'Never — only re-surface via manual rep search',
|
label: 'Never - only re-surface via manual rep search',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fallthrough_cooldown_days',
|
key: 'fallthrough_cooldown_days',
|
||||||
label: 'Recommender — fall-through cooldown (days)',
|
label: 'Recommender - fall-through cooldown (days)',
|
||||||
description:
|
description:
|
||||||
'Days a berth stays out of the recommender after a lost deal when the policy is `cooldown`. Default 30.',
|
'Days a berth stays out of the recommender after a lost deal when the policy is `cooldown`. Default 30.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -180,14 +180,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'heat_weight_recency',
|
key: 'heat_weight_recency',
|
||||||
label: 'Heat weight — recency',
|
label: 'Heat weight - recency',
|
||||||
description: 'Weight given to how recently the prior interest fell through. Default 30.',
|
description: 'Weight given to how recently the prior interest fell through. Default 30.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
defaultValue: 30,
|
defaultValue: 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'heat_weight_furthest_stage',
|
key: 'heat_weight_furthest_stage',
|
||||||
label: 'Heat weight — furthest stage',
|
label: 'Heat weight - furthest stage',
|
||||||
description:
|
description:
|
||||||
'Weight given to how close the prior interest got to closing before falling through. Default 40.',
|
'Weight given to how close the prior interest got to closing before falling through. Default 40.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -195,7 +195,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'heat_weight_interest_count',
|
key: 'heat_weight_interest_count',
|
||||||
label: 'Heat weight — historical interest count',
|
label: 'Heat weight - historical interest count',
|
||||||
description:
|
description:
|
||||||
'Weight given to how often this berth has attracted interest historically. Default 15.',
|
'Weight given to how often this berth has attracted interest historically. Default 15.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -203,7 +203,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'heat_weight_eoi_count',
|
key: 'heat_weight_eoi_count',
|
||||||
label: 'Heat weight — historical EOI count',
|
label: 'Heat weight - historical EOI count',
|
||||||
description:
|
description:
|
||||||
'Weight given to how often interest in this berth has reached EOI signing. Default 15.',
|
'Weight given to how often interest in this berth has reached EOI signing. Default 15.',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -211,7 +211,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tier_ladder_hide_late_stage',
|
key: 'tier_ladder_hide_late_stage',
|
||||||
label: 'Recommender — hide late-stage tier',
|
label: 'Recommender - hide late-stage tier',
|
||||||
description:
|
description:
|
||||||
'Hide berths whose only active interests are late-stage (close to closing) from recommendations.',
|
'Hide berths whose only active interests are late-stage (close to closing) from recommendations.',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -219,7 +219,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'documents_show_expired_tab',
|
key: 'documents_show_expired_tab',
|
||||||
label: 'Documents — show Expired tab',
|
label: 'Documents - show Expired tab',
|
||||||
description:
|
description:
|
||||||
'When off, the Expired tab on the documents hub is hidden. Use this when expired EOIs are noise that distracts reps from active deals.',
|
'When off, the Expired tab on the documents hub is hidden. Use this when expired EOIs are noise that distracts reps from active deals.',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -227,12 +227,15 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'berths_default_currency',
|
key: 'berths_default_currency',
|
||||||
label: 'Berths — default currency',
|
label: 'Berths - default currency',
|
||||||
description:
|
description:
|
||||||
'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.',
|
'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
defaultValue: 'USD',
|
defaultValue: 'USD',
|
||||||
options: SUPPORTED_CURRENCIES.map((c) => ({ value: c.code, label: `${c.code} — ${c.label}` })),
|
options: SUPPORTED_CURRENCIES.map((c) => ({
|
||||||
|
value: c.code,
|
||||||
|
label: `${c.code} - ${c.label}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -350,7 +353,7 @@ export function SettingsManager() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* String + Select Settings — both render in the same card.
|
{/* String + Select Settings - both render in the same card.
|
||||||
'select' settings get a Select dropdown bound to setting.options;
|
'select' settings get a Select dropdown bound to setting.options;
|
||||||
'string' settings get a free-text Input. */}
|
'string' settings get a free-text Input. */}
|
||||||
{KNOWN_SETTINGS.some((s) => s.type === 'string' || s.type === 'select') && (
|
{KNOWN_SETTINGS.some((s) => s.type === 'string' || s.type === 'select') && (
|
||||||
@@ -526,7 +529,7 @@ export function SettingsManager() {
|
|||||||
this for one-off feature flags, integration secrets, or experimental tunables that the
|
this for one-off feature flags, integration secrets, or experimental tunables that the
|
||||||
platform reads at runtime via{' '}
|
platform reads at runtime via{' '}
|
||||||
<code className="text-xs">getSystemSetting(portId, key)</code>. Values can be JSON
|
<code className="text-xs">getSystemSetting(portId, key)</code>. Values can be JSON
|
||||||
objects, plain strings, numbers, or booleans. Most reps will never need this section —
|
objects, plain strings, numbers, or booleans. Most reps will never need this section -
|
||||||
touch only if you know which key affects what.
|
touch only if you know which key affects what.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ function UserSelectInput({
|
|||||||
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
|
<SelectValue placeholder={isLoading ? 'Loading users…' : placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NONE}>— No CRM user linked —</SelectItem>
|
<SelectItem value={NONE}> - No CRM user linked - </SelectItem>
|
||||||
{(data?.data ?? []).map((u) => (
|
{(data?.data ?? []).map((u) => (
|
||||||
<SelectItem key={u.id} value={u.id}>
|
<SelectItem key={u.id} value={u.id}>
|
||||||
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
|
{u.name || u.email} {u.name ? `· ${u.email}` : ''}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const S3_FIELDS: SettingFieldDef[] = [
|
|||||||
// (`pnpm tsx scripts/encrypt-plaintext-credentials.ts`) this field is
|
// (`pnpm tsx scripts/encrypt-plaintext-credentials.ts`) this field is
|
||||||
// empty and the encrypted form takes over.
|
// empty and the encrypted form takes over.
|
||||||
key: 'storage_s3_access_key',
|
key: 'storage_s3_access_key',
|
||||||
label: 'S3 access key (legacy plaintext — deprecated)',
|
label: 'S3 access key (legacy plaintext - deprecated)',
|
||||||
description:
|
description:
|
||||||
'Deprecated. Use the AES-encrypted access key field below instead. After running the migration script, this row is removed and only the encrypted form is used.',
|
'Deprecated. Use the AES-encrypted access key field below instead. After running the migration script, this row is removed and only the encrypted form is used.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -222,13 +222,13 @@ export function StorageAdminPanel() {
|
|||||||
description="Where the CRM stores per-berth PDFs, brochures, GDPR exports, profile photos, and other binary files."
|
description="Where the CRM stores per-berth PDFs, brochures, GDPR exports, profile photos, and other binary files."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AES-encrypted access key — write path. The legacy plaintext access
|
{/* AES-encrypted access key - write path. The legacy plaintext access
|
||||||
key field below is read-only deprecation; new writes should go
|
key field below is read-only deprecation; new writes should go
|
||||||
through this card. After running the encrypt-plaintext-credentials
|
through this card. After running the encrypt-plaintext-credentials
|
||||||
migration script, the legacy field becomes empty. */}
|
migration script, the legacy field becomes empty. */}
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
title="S3 access key (encrypted)"
|
title="S3 access key (encrypted)"
|
||||||
description="AES-encrypted at rest. Type your access key here — it replaces the deprecated plaintext field below and fixes audit finding S-23."
|
description="AES-encrypted at rest. Type your access key here - it replaces the deprecated plaintext field below and fixes audit finding S-23."
|
||||||
sections={['storage.s3']}
|
sections={['storage.s3']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ export function StorageAdminPanel() {
|
|||||||
<div className="rounded-md border p-3 text-sm">
|
<div className="rounded-md border p-3 text-sm">
|
||||||
{testResult.ok ? (
|
{testResult.ok ? (
|
||||||
<div className="flex items-center gap-2 text-emerald-600">
|
<div className="flex items-center gap-2 text-emerald-600">
|
||||||
<CheckCircle2 className="h-4 w-4" aria-hidden /> Connection OK — round-trip
|
<CheckCircle2 className="h-4 w-4" aria-hidden /> Connection OK - round-trip
|
||||||
succeeded.
|
succeeded.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -271,7 +271,7 @@ export function StorageAdminPanel() {
|
|||||||
|
|
||||||
<SettingsFormCard
|
<SettingsFormCard
|
||||||
title="Filesystem configuration"
|
title="Filesystem configuration"
|
||||||
description="Used when the active backend is filesystem. Only single-node deployments — multi-node servers must use S3."
|
description="Used when the active backend is filesystem. Only single-node deployments - multi-node servers must use S3."
|
||||||
fields={FS_FIELDS}
|
fields={FS_FIELDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ export function StorageAdminPanel() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<strong>Switch + migrate</strong> copies every existing file to the new backend then
|
<strong>Switch + migrate</strong> copies every existing file to the new backend then
|
||||||
flips the pointer atomically. Reversible with a follow-up reverse-migration.{' '}
|
flips the pointer atomically. Reversible with a follow-up reverse-migration.{' '}
|
||||||
<strong>Switch only</strong> flips the pointer immediately — old files become
|
<strong>Switch only</strong> flips the pointer immediately - old files become
|
||||||
inaccessible until you migrate them or revert the backend.
|
inaccessible until you migrate them or revert the backend.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +400,7 @@ export function StorageAdminPanel() {
|
|||||||
{s.fileCount} existing file
|
{s.fileCount} existing file
|
||||||
{s.fileCount === 1 ? '' : 's'} on <code className="text-xs">{s.backend}</code> will
|
{s.fileCount === 1 ? '' : 's'} on <code className="text-xs">{s.backend}</code> will
|
||||||
not be reachable from the CRM after the switch unless you migrate them later. This is
|
not be reachable from the CRM after the switch unless you migrate them later. This is
|
||||||
rarely the right choice — prefer Switch + migrate.
|
rarely the right choice - prefer Switch + migrate.
|
||||||
</WarningCallout>
|
</WarningCallout>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function RecentErrorsPanel() {
|
|||||||
<div className="space-y-0.5 min-w-0">
|
<div className="space-y-0.5 min-w-0">
|
||||||
<p className="font-medium truncate">{error.message}</p>
|
<p className="font-medium truncate">{error.message}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{error.source === 'queue' ? 'Queue' : 'Audit'} —{' '}
|
{error.source === 'queue' ? 'Queue' : 'Audit'} -{' '}
|
||||||
{new Date(error.timestamp).toLocaleString()}
|
{new Date(error.timestamp).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
How this user appears across the app — usually their full name, but they can pick
|
How this user appears across the app - usually their full name, but they can pick
|
||||||
a nickname.
|
a nickname.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +238,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
|
|||||||
/>
|
/>
|
||||||
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
|
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
|
||||||
<p className="text-xs text-amber-600">
|
<p className="text-xs text-amber-600">
|
||||||
You'll be asked to confirm — the original address will receive an automated
|
You'll be asked to confirm - the original address will receive an automated
|
||||||
notice that you, the admin, changed their sign-in email.
|
notice that you, the admin, changed their sign-in email.
|
||||||
</p>
|
</p>
|
||||||
) : isEdit ? (
|
) : isEdit ? (
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function UserPermissionMatrix({ userId }: UserPermissionMatrixProps) {
|
|||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
|
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
Super-admin users bypass per-port permission checks. Overrides don't apply here —
|
Super-admin users bypass per-port permission checks. Overrides don't apply here -
|
||||||
revoke the super-admin flag on the Profile tab first.
|
revoke the super-admin flag on the Profile tab first.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function ActiveInterestsPopover({ berthId, portSlug, count }: Props) {
|
|||||||
// inside the conditionally-rendered PopoverContent.
|
// inside the conditionally-rendered PopoverContent.
|
||||||
});
|
});
|
||||||
|
|
||||||
if (count === 0) return <span className="text-muted-foreground">—</span>;
|
if (count === 0) return <span className="text-muted-foreground"> - </span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function BerthDealDocumentsTab({ berthId }: { berthId: string }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
EOIs, contracts, and other deal documents attached to interests currently linked to this
|
EOIs, contracts, and other deal documents attached to interests currently linked to this
|
||||||
berth. Read-only — to send, sign, or edit, open the document on the linked interest's
|
berth. Read-only - to send, sign, or edit, open the document on the linked interest's
|
||||||
page.
|
page.
|
||||||
</p>
|
</p>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
title/area block sits side-by-side with the action buttons. */}
|
title/area block sits side-by-side with the action buttons. */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
|
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
|
||||||
{/* Compact mooring chip — the mooring number sits inside a
|
{/* Compact mooring chip - the mooring number sits inside a
|
||||||
rounded plate tinted by the mooring-letter palette (same
|
rounded plate tinted by the mooring-letter palette (same
|
||||||
colour used for the row-accent in the berth list). The
|
colour used for the row-accent in the berth list). The
|
||||||
redundant "B Dock" tag from the previous design is replaced
|
redundant "B Dock" tag from the previous design is replaced
|
||||||
@@ -367,7 +367,7 @@ function InterestLinkPicker({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'— No interest —'
|
' - No interest - '
|
||||||
)}
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -386,7 +386,7 @@ function InterestLinkPicker({
|
|||||||
}}
|
}}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
— No interest —
|
- No interest -
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Most recent first">
|
<CommandGroup heading="Most recent first">
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Pro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This action is reversible — restore individually from each archived client.
|
This action is reversible - restore individually from each archived client.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props)
|
|||||||
{stage === 'partial' && (
|
{stage === 'partial' && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
|
||||||
{partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped —
|
{partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped -
|
||||||
see below.
|
see below.
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border max-h-60 overflow-y-auto">
|
<div className="rounded-md border max-h-60 overflow-y-auto">
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ function SmartArchiveDialogBody({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Archive {clientName}</DialogTitle>
|
<DialogTitle>Archive {clientName}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Archive is reversible — the client can be restored from the archived list. Decide what
|
Archive is reversible - the client can be restored from the archived list. Decide what
|
||||||
should happen to the relationships below before continuing.
|
should happen to the relationships below before continuing.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -314,7 +314,7 @@ function SmartArchiveDialogBody({
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-amber-900 flex items-center gap-2">
|
<CardTitle className="text-sm font-medium text-amber-900 flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4" aria-hidden />
|
<AlertTriangle className="h-4 w-4" aria-hidden />
|
||||||
Late-stage interest — confirmation required
|
Late-stage interest - confirmation required
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-xs text-amber-900">
|
<CardContent className="text-xs text-amber-900">
|
||||||
@@ -569,12 +569,12 @@ function SmartArchiveDialogBody({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-xs text-muted-foreground space-y-1">
|
<CardContent className="text-xs text-muted-foreground space-y-1">
|
||||||
<p>EOI documents — retained for audit (always)</p>
|
<p>EOI documents - retained for audit (always)</p>
|
||||||
{dossier.hasPortalUser && <p>Portal user — deactivated (login revoked)</p>}
|
{dossier.hasPortalUser && <p>Portal user - deactivated (login revoked)</p>}
|
||||||
{dossier.companies.length > 0 && (
|
{dossier.companies.length > 0 && (
|
||||||
<p>Company memberships — end-dated to today (history preserved)</p>
|
<p>Company memberships - end-dated to today (history preserved)</p>
|
||||||
)}
|
)}
|
||||||
<p>Notes, contacts, tags, addresses — survive on the archived client</p>
|
<p>Notes, contacts, tags, addresses - survive on the archived client</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function SmartRestoreDialogBody({ open, onOpenChange, clientId, clientName, onSu
|
|||||||
<div key={r.id} className="flex items-start gap-2">
|
<div key={r.id} className="flex items-start gap-2">
|
||||||
<span className="mt-0.5">{iconFor(r.kind)}</span>
|
<span className="mt-0.5">{iconFor(r.kind)}</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium">{r.label}</span> — {r.reason}
|
<span className="font-medium">{r.label}</span> - {r.reason}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -217,7 +217,7 @@ function SmartRestoreDialogBody({ open, onOpenChange, clientId, clientName, onSu
|
|||||||
<div key={r.id} className="flex items-start gap-2">
|
<div key={r.id} className="flex items-start gap-2">
|
||||||
<span className="mt-0.5">{iconFor(r.kind)}</span>
|
<span className="mt-0.5">{iconFor(r.kind)}</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium">{r.label}</span> — {r.reason}.{' '}
|
<span className="font-medium">{r.label}</span> - {r.reason}.{' '}
|
||||||
<span className="italic">{r.lockReason}</span>
|
<span className="italic">{r.lockReason}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function CustomizeWidgetsMenu() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Customize dashboard</DialogTitle>
|
<DialogTitle>Customize dashboard</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty space —
|
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty space -
|
||||||
the layout reflows to fill the available width.
|
the layout reflows to fill the available width.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ export function PipelineValueTile() {
|
|||||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||||
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Each pipeline stage has a close-probability — how likely a deal at that stage is to
|
Each pipeline stage has a close-probability - how likely a deal at that stage is to
|
||||||
actually close. Multiplying the berth price by the stage weight gives an{' '}
|
actually close. Multiplying the berth price by the stage weight gives an{' '}
|
||||||
<strong>expected</strong> value for that deal. Summing across every active deal
|
<strong>expected</strong> value for that deal. Summing across every active deal
|
||||||
yields the weighted forecast — a defensible “what will likely land”
|
yields the weighted forecast - a defensible “what will likely land”
|
||||||
number, vs the gross which assumes every deal closes at full value.
|
number, vs the gross which assumes every deal closes at full value.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
||||||
@@ -205,7 +205,7 @@ export function PipelineValueTile() {
|
|||||||
{s.dealsMissingPrice > 0 ? (
|
{s.dealsMissingPrice > 0 ? (
|
||||||
<p
|
<p
|
||||||
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
||||||
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
|
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set - gross is undercounted here.`}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" aria-hidden />
|
<AlertTriangle className="size-3" aria-hidden />
|
||||||
{s.dealsMissingPrice === s.count
|
{s.dealsMissingPrice === s.count
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="documenso-template">
|
<SelectItem value="documenso-template">
|
||||||
Generated EOI — rendered + signed externally
|
Generated EOI - rendered + signed externally
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="inapp">
|
<SelectItem value="inapp">
|
||||||
Manual EOI — rendered in CRM, sent for e-signature
|
Manual EOI - rendered in CRM, sent for e-signature
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: Eoi
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-xs italic text-muted-foreground">
|
<p className="text-xs italic text-muted-foreground">
|
||||||
Leave all unchecked to cancel silently — no emails will be sent.
|
Leave all unchecked to cancel silently - no emails will be sent.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function EoiGenerateDialog({
|
|||||||
|
|
||||||
const persistMissingFields = async (): Promise<void> => {
|
const persistMissingFields = async (): Promise<void> => {
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
toastError(new Error('Client ID missing — refresh the page.'));
|
toastError(new Error('Client ID missing - refresh the page.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFixSaving(true);
|
setFixSaving(true);
|
||||||
@@ -363,7 +363,7 @@ export function EoiGenerateDialog({
|
|||||||
{
|
{
|
||||||
key: 'dimensions',
|
key: 'dimensions',
|
||||||
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
|
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
|
||||||
value: ctx.yacht ? dimensionsForRender.map((v) => v ?? '—').join(' × ') : null,
|
value: ctx.yacht ? dimensionsForRender.map((v) => v ?? ' - ').join(' × ') : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'berth',
|
key: 'berth',
|
||||||
@@ -481,7 +481,7 @@ export function EoiGenerateDialog({
|
|||||||
Generate Expression of Interest
|
Generate Expression of Interest
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Review the values that will be auto-filled. Edit anything inline — changes save back to
|
Review the values that will be auto-filled. Edit anything inline - changes save back to
|
||||||
the client / interest record automatically. The EOI is generated once everything looks
|
the client / interest record automatically. The EOI is generated once everything looks
|
||||||
right.
|
right.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
@@ -497,7 +497,7 @@ export function EoiGenerateDialog({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
|
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
|
||||||
Standard EOI — sent for e-signature (recommended)
|
Standard EOI - sent for e-signature (recommended)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{inAppTemplates.map((t) => (
|
{inAppTemplates.map((t) => (
|
||||||
<SelectItem key={t.id} value={t.id}>
|
<SelectItem key={t.id} value={t.id}>
|
||||||
@@ -555,7 +555,7 @@ export function EoiGenerateDialog({
|
|||||||
<div className="space-y-1 border-t pt-2">
|
<div className="space-y-1 border-t pt-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Optional (Section 3 — left blank if absent)
|
Optional (Section 3 - left blank if absent)
|
||||||
</p>
|
</p>
|
||||||
{ctx.yacht ? (
|
{ctx.yacht ? (
|
||||||
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
|
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
|
||||||
@@ -662,7 +662,7 @@ export function EoiGenerateDialog({
|
|||||||
Missing required client details
|
Missing required client details
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-amber-800/80">
|
<p className="text-[11px] text-amber-800/80">
|
||||||
Fill the fields below — they'll be saved to the client's record before
|
Fill the fields below - they'll be saved to the client's record before
|
||||||
the EOI renders.
|
the EOI renders.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -804,7 +804,7 @@ export function EoiGenerateDialog({
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
||||||
{/* Phase 3c — nested yacht-spawn Sheet. Pre-selects the linked
|
{/* Phase 3c - nested yacht-spawn Sheet. Pre-selects the linked
|
||||||
client as owner so the rep only types the yacht-specific
|
client as owner so the rep only types the yacht-specific
|
||||||
fields. After save, PATCH the interest with the new yachtId so
|
fields. After save, PATCH the interest with the new yachtId so
|
||||||
the EOI's yacht block populates without a manual re-link. */}
|
the EOI's yacht block populates without a manual re-link. */}
|
||||||
@@ -917,7 +917,9 @@ function PreviewRow({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1">{value ?? (missing ? 'Missing — required' : 'Not set')}</span>
|
<span className="flex-1">
|
||||||
|
{value ?? (missing ? 'Missing - required' : 'Not set')}
|
||||||
|
</span>
|
||||||
{edit ? (
|
{edit ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1004,7 +1006,7 @@ function OverridableContactField({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{effective ?? (missing ? 'Missing — required' : 'Not set')}
|
{effective ?? (missing ? 'Missing - required' : 'Not set')}
|
||||||
{override?.value != null ? (
|
{override?.value != null ? (
|
||||||
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
|
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
|
||||||
[EOI]
|
[EOI]
|
||||||
@@ -1265,7 +1267,7 @@ function OverridableAddressField({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{effectiveSummary ?? (missing ? 'Missing — required' : 'Not set')}
|
{effectiveSummary ?? (missing ? 'Missing - required' : 'Not set')}
|
||||||
{override ? (
|
{override ? (
|
||||||
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
|
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
|
||||||
[EOI]
|
[EOI]
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function NewDocumentMenu({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>Upload file</span>
|
<span>Upload file</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Drop or browse — stored in the current folder
|
Drop or browse - stored in the current folder
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -83,7 +83,7 @@ export function NewDocumentMenu({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>Generate document for signing</span>
|
<span>Generate document for signing</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
EOI, contract, or custom — sent for e-signature
|
EOI, contract, or custom - sent for e-signature
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
|||||||
<PopoverContent side="bottom" align="start" className="w-80 p-4 space-y-3">
|
<PopoverContent side="bottom" align="start" className="w-80 p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
Deal pulse — {label} ({health.score} / 100)
|
Deal pulse - {label} ({health.score} / 100)
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
How likely this deal is to keep moving forward, scored from 0 to 100.
|
How likely this deal is to keep moving forward, scored from 0 to 100.
|
||||||
@@ -70,7 +70,7 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
|||||||
</p>
|
</p>
|
||||||
{health.signals.length === 0 ? (
|
{health.signals.length === 0 ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Nothing notable yet — the score is sitting at the baseline (50). Log a contact,
|
Nothing notable yet - the score is sitting at the baseline (50). Log a contact,
|
||||||
progress the stage, or send a signing request and you'll see the dial move.
|
progress the stage, or send a signing request and you'll see the dial move.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ export function InlineStagePicker({
|
|||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This interest has {linkedBerthCount} linked{' '}
|
This interest has {linkedBerthCount} linked{' '}
|
||||||
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to{' '}
|
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to{' '}
|
||||||
<strong>New Enquiry</strong> usually means restarting the lead — keeping the berth
|
<strong>New Enquiry</strong> usually means restarting the lead - keeping the berth
|
||||||
links would leave them showing as under offer on the public map for a deal that's
|
links would leave them showing as under offer on the public map for a deal that's
|
||||||
no longer in progress.
|
no longer in progress.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|||||||
@@ -431,14 +431,14 @@ function ComposeDialogBody({
|
|||||||
<SheetTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Record the channel, the direction, and what was discussed. Optionally schedule a
|
Record the channel, the direction, and what was discussed. Optionally schedule a
|
||||||
follow-up — a reminder will be created automatically.
|
follow-up - a reminder will be created automatically.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-1">
|
<div className="space-y-3 py-1">
|
||||||
{/* Quick-template buttons. Tap one to seed the channel + direction
|
{/* Quick-template buttons. Tap one to seed the channel + direction
|
||||||
+ a starter summary so the rep can focus on the substance.
|
+ a starter summary so the rep can focus on the substance.
|
||||||
Hidden when editing — templates are a fresh-entry affordance. */}
|
Hidden when editing - templates are a fresh-entry affordance. */}
|
||||||
{!isEdit ? (
|
{!isEdit ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
|
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
|
|||||||
|
|
||||||
{/* Reuses the external-EOI upload dialog. The endpoint
|
{/* Reuses the external-EOI upload dialog. The endpoint
|
||||||
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
|
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
|
||||||
— for contract paper-uploads we'll need the equivalent
|
- for contract paper-uploads we'll need the equivalent
|
||||||
contract endpoint (deferred to a follow-up; the dialog UI
|
contract endpoint (deferred to a follow-up; the dialog UI
|
||||||
is the pattern we'll clone). For now the flow is documented
|
is the pattern we'll clone). For now the flow is documented
|
||||||
as 'coming soon' rather than misrouting through EOI. */}
|
as 'coming soon' rather than misrouting through EOI. */}
|
||||||
@@ -174,7 +174,7 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase 4 — upload-for-Documenso-signing dialog. Multi-step
|
{/* Phase 4 - upload-for-Documenso-signing dialog. Multi-step
|
||||||
(file → recipients → fields → send) backed by the Phase 3
|
(file → recipients → fields → send) backed by the Phase 3
|
||||||
service. Auto-detect runs after the file lands; rep can
|
service. Auto-detect runs after the file lands; rep can
|
||||||
tweak placements before sending. */}
|
tweak placements before sending. */}
|
||||||
@@ -187,7 +187,7 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* "Mark as signed externally" — flips the contract doc-status
|
{/* "Mark as signed externally" - flips the contract doc-status
|
||||||
to 'signed' without uploading a file. Used when the rep is
|
to 'signed' without uploading a file. Used when the rep is
|
||||||
keeping the canonical copy elsewhere and just wants the CRM
|
keeping the canonical copy elsewhere and just wants the CRM
|
||||||
state to reflect the close. */}
|
state to reflect the close. */}
|
||||||
@@ -299,7 +299,7 @@ function ActiveContractCard({
|
|||||||
</div>
|
</div>
|
||||||
) : signers.length === 0 ? (
|
) : signers.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">
|
<p className="text-sm text-muted-foreground italic">
|
||||||
The signing service hasn't reported signers yet — check back in a moment.
|
The signing service hasn't reported signers yet - check back in a moment.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<SigningProgress documentId={doc.id} signers={signers} />
|
<SigningProgress documentId={doc.id} signers={signers} />
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* History strip — completed + cancelled EOIs from earlier in the
|
{/* History strip - completed + cancelled EOIs from earlier in the
|
||||||
deal's life. Quiet and skimmable; the active document above
|
deal's life. Quiet and skimmable; the active document above
|
||||||
carries the day-to-day attention. */}
|
carries the day-to-day attention. */}
|
||||||
{completedDocs.length > 0 && (
|
{completedDocs.length > 0 && (
|
||||||
@@ -347,7 +347,7 @@ function ActiveEoiCard({
|
|||||||
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
|
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
|
||||||
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
|
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
|
||||||
</span>
|
</span>
|
||||||
{/* Signing-order badge — tells the team whether recipients
|
{/* Signing-order badge - tells the team whether recipients
|
||||||
must sign in order or can sign concurrently. Drives off
|
must sign in order or can sign concurrently. Drives off
|
||||||
the per-port setting; for v2 templates the template's
|
the per-port setting; for v2 templates the template's
|
||||||
stored order wins server-side and we still surface our
|
stored order wins server-side and we still surface our
|
||||||
@@ -361,7 +361,7 @@ function ActiveEoiCard({
|
|||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
signingOrder === 'SEQUENTIAL'
|
signingOrder === 'SEQUENTIAL'
|
||||||
? 'Signers receive the invite chain one at a time — each must sign before the next is emailed.'
|
? 'Signers receive the invite chain one at a time - each must sign before the next is emailed.'
|
||||||
: 'All signers receive the invite at once and can sign in any order.'
|
: 'All signers receive the invite at once and can sign in any order.'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -386,7 +386,7 @@ function ActiveEoiCard({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Remind all hides once every signer is signed — no-one to nudge. */}
|
{/* Remind all hides once every signer is signed - no-one to nudge. */}
|
||||||
{!effectivelyCompleted && (
|
{!effectivelyCompleted && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -416,7 +416,7 @@ function ActiveEoiCard({
|
|||||||
</div>
|
</div>
|
||||||
) : signers.length === 0 ? (
|
) : signers.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">
|
<p className="text-sm text-muted-foreground italic">
|
||||||
The signing service hasn't reported signers yet — check back in a moment.
|
The signing service hasn't reported signers yet - check back in a moment.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<SigningProgress documentId={doc.id} signers={signers} />
|
<SigningProgress documentId={doc.id} signers={signers} />
|
||||||
@@ -442,7 +442,7 @@ function ActiveEoiCard({
|
|||||||
{/* Footer hides once every signer is signed: Cancel + Remind reminder
|
{/* Footer hides once every signer is signed: Cancel + Remind reminder
|
||||||
stop making sense, and the rep's natural next action is to view
|
stop making sense, and the rep's natural next action is to view
|
||||||
the signed PDF (rendered above) or open the linked document
|
the signed PDF (rendered above) or open the linked document
|
||||||
detail page. Upload-paper-signed-copy stays available — useful
|
detail page. Upload-paper-signed-copy stays available - useful
|
||||||
for in-person sign-out workflows even after the digital flow. */}
|
for in-person sign-out workflows even after the digital flow. */}
|
||||||
{!effectivelyCompleted ? (
|
{!effectivelyCompleted ? (
|
||||||
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
@@ -461,7 +461,7 @@ function ActiveEoiCard({
|
|||||||
<Upload />
|
<Upload />
|
||||||
Upload paper-signed copy
|
Upload paper-signed copy
|
||||||
</Button>
|
</Button>
|
||||||
{/* Regenerate is only safe when no one has signed yet — once
|
{/* Regenerate is only safe when no one has signed yet - once
|
||||||
signatures are on the doc, the rep must go through the
|
signatures are on the doc, the rep must go through the
|
||||||
cancel-with-notify path so collaborators learn about the
|
cancel-with-notify path so collaborators learn about the
|
||||||
discard. */}
|
discard. */}
|
||||||
@@ -474,7 +474,7 @@ function ActiveEoiCard({
|
|||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Regenerate this EOI?',
|
title: 'Regenerate this EOI?',
|
||||||
description:
|
description:
|
||||||
'The current envelope will be voided silently — no recipients will be notified — and the generate dialog will re-open so you can rebuild.',
|
'The current envelope will be voided silently - no recipients will be notified - and the generate dialog will re-open so you can rebuild.',
|
||||||
confirmLabel: 'Regenerate',
|
confirmLabel: 'Regenerate',
|
||||||
});
|
});
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -551,7 +551,7 @@ function SignedPdfPreview({ fileId }: { fileId: string }) {
|
|||||||
if (isError || !data?.data.url) {
|
if (isError || !data?.data.url) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs italic text-muted-foreground">
|
<p className="text-xs italic text-muted-foreground">
|
||||||
Preview unavailable — use the Download button to grab the signed PDF.
|
Preview unavailable - use the Download button to grab the signed PDF.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function InterestReservationTab({
|
|||||||
|
|
||||||
{/* Reuses the external-EOI upload dialog. The endpoint
|
{/* Reuses the external-EOI upload dialog. The endpoint
|
||||||
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
|
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
|
||||||
— for reservation paper-uploads we'll need the equivalent
|
- for reservation paper-uploads we'll need the equivalent
|
||||||
reservation endpoint (deferred to a follow-up; the dialog UI
|
reservation endpoint (deferred to a follow-up; the dialog UI
|
||||||
is the pattern we'll clone). For now the flow is documented
|
is the pattern we'll clone). For now the flow is documented
|
||||||
as 'coming soon' rather than misrouting through EOI. */}
|
as 'coming soon' rather than misrouting through EOI. */}
|
||||||
@@ -177,7 +177,7 @@ export function InterestReservationTab({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase 4 — upload-for-Documenso-signing dialog. */}
|
{/* Phase 4 - upload-for-Documenso-signing dialog. */}
|
||||||
{uploadForSigningOpen && (
|
{uploadForSigningOpen && (
|
||||||
<UploadForSigningDialog
|
<UploadForSigningDialog
|
||||||
open={uploadForSigningOpen}
|
open={uploadForSigningOpen}
|
||||||
@@ -295,7 +295,7 @@ function ActiveReservationCard({
|
|||||||
</div>
|
</div>
|
||||||
) : signers.length === 0 ? (
|
) : signers.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">
|
<p className="text-sm text-muted-foreground italic">
|
||||||
The signing service hasn't reported signers yet — check back in a moment.
|
The signing service hasn't reported signers yet - check back in a moment.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<SigningProgress documentId={doc.id} signers={signers} />
|
<SigningProgress documentId={doc.id} signers={signers} />
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function InterestStagePicker({
|
|||||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden />
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden />
|
||||||
{canOverride ? (
|
{canOverride ? (
|
||||||
<span>
|
<span>
|
||||||
This is not a normal forward transition. Override is enabled — supply a reason
|
This is not a normal forward transition. Override is enabled - supply a reason
|
||||||
below explaining the manual stage change. Recorded in the audit log.
|
below explaining the manual stage change. Recorded in the audit log.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -138,7 +138,7 @@ export function InterestStagePicker({
|
|||||||
checked={override}
|
checked={override}
|
||||||
onChange={(e) => setOverride(e.target.checked)}
|
onChange={(e) => setOverride(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Force-override (skip transition rules) — requires a reason
|
Force-override (skip transition rules) - requires a reason
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ function MilestoneAdvanceButton({
|
|||||||
placeholder="Pick a date"
|
placeholder="Pick a date"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Defaults to today — back-date if the event happened earlier.
|
Defaults to today - back-date if the event happened earlier.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -962,11 +962,11 @@ function OverviewTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Skip-ahead nudge — informational only; fires when the deal jumped
|
{/* Skip-ahead nudge - informational only; fires when the deal jumped
|
||||||
past a milestone without stamping the matching date. */}
|
past a milestone without stamping the matching date. */}
|
||||||
<SkipAheadBanner interest={interest} />
|
<SkipAheadBanner interest={interest} />
|
||||||
|
|
||||||
{/* Conflict callout — fires when a linked berth is sold or already
|
{/* Conflict callout - fires when a linked berth is sold or already
|
||||||
under offer to another active deal. Doesn't block the rep; just
|
under offer to another active deal. Doesn't block the rep; just
|
||||||
surfaces the situation so they treat the deal as a backup. */}
|
surfaces the situation so they treat the deal as a backup. */}
|
||||||
<InterestBerthStatusBanner
|
<InterestBerthStatusBanner
|
||||||
@@ -976,22 +976,22 @@ function OverviewTab({
|
|||||||
archivedAt={null}
|
archivedAt={null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Qualification checklist — surfaces the port's per-port criteria so
|
{/* Qualification checklist - surfaces the port's per-port criteria so
|
||||||
the rep can mark each one confirmed before the deal advances out
|
the rep can mark each one confirmed before the deal advances out
|
||||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||||
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
|
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
|
||||||
|
|
||||||
{/* Payments — bank-issued invoices live elsewhere; this is the
|
{/* Payments - bank-issued invoices live elsewhere; this is the
|
||||||
internal audit record of money received against the deal. The
|
internal audit record of money received against the deal. The
|
||||||
running deposit total here drives the auto-advance into the
|
running deposit total here drives the auto-advance into the
|
||||||
deposit_paid stage server-side. Hidden before the reservation
|
deposit_paid stage server-side. Hidden before the reservation
|
||||||
stage: no deposit is expected yet, so the empty card is just
|
stage: no deposit is expected yet, so the empty card is just
|
||||||
noise — the next-milestone card carries the actionable copy
|
noise - the next-milestone card carries the actionable copy
|
||||||
instead. Render order: deprioritized below the milestone strip
|
instead. Render order: deprioritized below the milestone strip
|
||||||
so the rep's eye lands on the active step first. */}
|
so the rep's eye lands on the active step first. */}
|
||||||
{/* Pre-reservation: the dedicated "Next step" guidance card was
|
{/* Pre-reservation: the dedicated "Next step" guidance card was
|
||||||
removed in favour of a brighter NEXT STEP pill on the active
|
removed in favour of a brighter NEXT STEP pill on the active
|
||||||
MilestoneSection below (it already owns the workflow actions —
|
MilestoneSection below (it already owns the workflow actions -
|
||||||
two surfaces was redundant). Nurturing keeps a slim helper
|
two surfaces was redundant). Nurturing keeps a slim helper
|
||||||
since no milestone is naturally "current" while a deal is
|
since no milestone is naturally "current" while a deal is
|
||||||
paused. */}
|
paused. */}
|
||||||
@@ -1005,7 +1005,7 @@ function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Sales-process milestones — phase-aware so the user only sees
|
{/* Sales-process milestones - phase-aware so the user only sees
|
||||||
what's actionable now. Past milestones collapse into a tight
|
what's actionable now. Past milestones collapse into a tight
|
||||||
history strip; the current milestone gets the full card; future
|
history strip; the current milestone gets the full card; future
|
||||||
milestones are hidden behind a toggle so reps can still
|
milestones are hidden behind a toggle so reps can still
|
||||||
@@ -1097,7 +1097,7 @@ function OverviewTab({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact — client's primary email + phone (from the linked client
|
{/* Contact - client's primary email + phone (from the linked client
|
||||||
record) AND the first/last-contact activity dates from the
|
record) AND the first/last-contact activity dates from the
|
||||||
contact log. Phone is rendered via libphonenumber-js's
|
contact log. Phone is rendered via libphonenumber-js's
|
||||||
international formatter so `+33633219796` reads as
|
international formatter so `+33633219796` reads as
|
||||||
@@ -1125,7 +1125,7 @@ function OverviewTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground"> - </span>
|
||||||
)}
|
)}
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Phone">
|
<EditableRow label="Phone">
|
||||||
@@ -1150,7 +1150,7 @@ function OverviewTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground"> - </span>
|
||||||
)}
|
)}
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
{interest.dateFirstContact || interest.dateLastContact ? (
|
{interest.dateFirstContact || interest.dateLastContact ? (
|
||||||
@@ -1160,7 +1160,7 @@ function OverviewTab({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-xs text-muted-foreground italic">
|
<p className="mt-1 text-xs text-muted-foreground italic">
|
||||||
No contact activity logged yet — log a call, email, or meeting from the Contact log
|
No contact activity logged yet - log a call, email, or meeting from the Contact log
|
||||||
tab to start tracking.
|
tab to start tracking.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1170,7 +1170,7 @@ function OverviewTab({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Berth requirements — desired length / width / draft. Editable
|
{/* Berth requirements - desired length / width / draft. Editable
|
||||||
inline so reps can capture or correct a buyer's needs without
|
inline so reps can capture or correct a buyer's needs without
|
||||||
leaving the Overview tab. These values drive the auto-tick on
|
leaving the Overview tab. These values drive the auto-tick on
|
||||||
the "Dimensions confirmed" qualification row + the
|
the "Dimensions confirmed" qualification row + the
|
||||||
@@ -1183,7 +1183,7 @@ function OverviewTab({
|
|||||||
value={interest.desiredLengthFt ?? null}
|
value={interest.desiredLengthFt ?? null}
|
||||||
onSave={save('desiredLengthFt')}
|
onSave={save('desiredLengthFt')}
|
||||||
placeholder="e.g. 60"
|
placeholder="e.g. 60"
|
||||||
emptyText="—"
|
emptyText=" - "
|
||||||
/>
|
/>
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Desired width (ft)">
|
<EditableRow label="Desired width (ft)">
|
||||||
@@ -1191,7 +1191,7 @@ function OverviewTab({
|
|||||||
value={interest.desiredWidthFt ?? null}
|
value={interest.desiredWidthFt ?? null}
|
||||||
onSave={save('desiredWidthFt')}
|
onSave={save('desiredWidthFt')}
|
||||||
placeholder="e.g. 25"
|
placeholder="e.g. 25"
|
||||||
emptyText="—"
|
emptyText=" - "
|
||||||
/>
|
/>
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Desired draft (ft)">
|
<EditableRow label="Desired draft (ft)">
|
||||||
@@ -1199,7 +1199,7 @@ function OverviewTab({
|
|||||||
value={interest.desiredDraftFt ?? null}
|
value={interest.desiredDraftFt ?? null}
|
||||||
onSave={save('desiredDraftFt')}
|
onSave={save('desiredDraftFt')}
|
||||||
placeholder="e.g. 6"
|
placeholder="e.g. 6"
|
||||||
emptyText="—"
|
emptyText=" - "
|
||||||
/>
|
/>
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -1215,7 +1215,7 @@ function OverviewTab({
|
|||||||
{/* Most-recent threaded note teaser. Saves a click into the Notes
|
{/* Most-recent threaded note teaser. Saves a click into the Notes
|
||||||
tab when the rep just wants to peek at "what was discussed last."
|
tab when the rep just wants to peek at "what was discussed last."
|
||||||
Always rendered now that the redundant `interests.notes` blob is
|
Always rendered now that the redundant `interests.notes` blob is
|
||||||
gone — falls back to an empty-state prompt so reps still have an
|
gone - falls back to an empty-state prompt so reps still have an
|
||||||
obvious entry point to the Notes tab from Overview. */}
|
obvious entry point to the Notes tab from Overview. */}
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
@@ -1271,7 +1271,7 @@ function OverviewTab({
|
|||||||
what's already linked before browsing more options. Each row exposes
|
what's already linked before browsing more options. Each row exposes
|
||||||
per-berth role-flag toggles and the EOI bypass control (only visible
|
per-berth role-flag toggles and the EOI bypass control (only visible
|
||||||
once the parent interest's primary EOI is signed). */}
|
once the parent interest's primary EOI is signed). */}
|
||||||
{/* Won-status wrap-up checklist — only renders when this interest's
|
{/* Won-status wrap-up checklist - only renders when this interest's
|
||||||
outcome is `won`. Surfaces upload slots for the manual paperwork
|
outcome is `won`. Surfaces upload slots for the manual paperwork
|
||||||
that didn't flow through the EOI->Contract chain automatically. */}
|
that didn't flow through the EOI->Contract chain automatically. */}
|
||||||
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
|
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
|
||||||
@@ -1298,7 +1298,7 @@ function OverviewTab({
|
|||||||
{confirmDialog}
|
{confirmDialog}
|
||||||
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
||||||
footer button can launch the dialog without leaving the tab. Same
|
footer button can launch the dialog without leaving the tab. Same
|
||||||
dialog component the dedicated EOI tab uses — single source of
|
dialog component the dedicated EOI tab uses - single source of
|
||||||
truth for the editing/confirmation flow. */}
|
truth for the editing/confirmation flow. */}
|
||||||
<EoiGenerateDialog
|
<EoiGenerateDialog
|
||||||
interestId={interestId}
|
interestId={interestId}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function formatDimensions(
|
|||||||
const SPECIFIC_CONSEQUENCE_ON =
|
const SPECIFIC_CONSEQUENCE_ON =
|
||||||
'This berth will show as “Under Offer” on the public-facing marina map.';
|
'This berth will show as “Under Offer” on the public-facing marina map.';
|
||||||
const SPECIFIC_CONSEQUENCE_OFF =
|
const SPECIFIC_CONSEQUENCE_OFF =
|
||||||
'This berth stays marked “Available” on the public map — the link is internal only.';
|
'This berth stays marked “Available” on the public map - the link is internal only.';
|
||||||
|
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ function LinkedBerthRowItem({
|
|||||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||||
flexed to the far right via justify-between — when the
|
flexed to the far right via justify-between - when the
|
||||||
column is wide, justify-between created a confusing visual
|
column is wide, justify-between created a confusing visual
|
||||||
gulf between the action and what it controls. */}
|
gulf between the action and what it controls. */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -477,7 +477,7 @@ function LinkedBerthRowItem({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove berth {row.mooringNumber} from interest?</DialogTitle>
|
<DialogTitle>Remove berth {row.mooringNumber} from interest?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
The berth itself isn't deleted — only its link to this interest.
|
The berth itself isn't deleted - only its link to this interest.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="gap-2 sm:gap-2">
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
@@ -707,7 +707,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
|||||||
<>
|
<>
|
||||||
<BerthSection
|
<BerthSection
|
||||||
title="Deal berth"
|
title="Deal berth"
|
||||||
hint="The one berth this interest is anchored to — drives templates, the EOI primary slot, and the public-map status. Promote any other berth to take its place."
|
hint="The one berth this interest is anchored to - drives templates, the EOI primary slot, and the public-map status. Promote any other berth to take its place."
|
||||||
emptyText="No deal berth selected. Pick one of the linked berths below as the primary."
|
emptyText="No deal berth selected. Pick one of the linked berths below as the primary."
|
||||||
count={dealBerth ? 1 : 0}
|
count={dealBerth ? 1 : 0}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function PaymentsSection({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Payments</h3>
|
<h3 className="text-sm font-semibold">Payments</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Records that money was received or refunded. No invoices are issued — the bank handles
|
Records that money was received or refunded. No invoices are issued - the bank handles
|
||||||
that.
|
that.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,8 +274,8 @@ function RecordPaymentSheet({
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Record payment</SheetTitle>
|
<SheetTitle>Record payment</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Capture that money was received (or refunded). Reps don't issue invoices — the bank
|
Capture that money was received (or refunded). Reps don't issue invoices - the bank
|
||||||
does that — so this is just an audit record.
|
does that - so this is just an audit record.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
|
|||||||
{allData?.truncated ? (
|
{allData?.truncated ? (
|
||||||
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
|
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
|
||||||
deals aren't on the board — archive completed work to keep the kanban readable.
|
deals aren't on the board - archive completed work to keep the kanban readable.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function QualificationChecklist({
|
|||||||
{showPromoteHint ? (
|
{showPromoteHint ? (
|
||||||
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">
|
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">
|
||||||
<p className="text-xs text-emerald-800">
|
<p className="text-xs text-emerald-800">
|
||||||
All criteria confirmed — this lead is ready to qualify.
|
All criteria confirmed - this lead is ready to qualify.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
|
|||||||
<CardHeader className="gap-1">
|
<CardHeader className="gap-1">
|
||||||
<CardTitle className="flex items-center gap-2 text-base text-emerald-900">
|
<CardTitle className="flex items-center gap-2 text-base text-emerald-900">
|
||||||
<Trophy className="size-4" aria-hidden />
|
<Trophy className="size-4" aria-hidden />
|
||||||
Won — wrap-up checklist
|
Won - wrap-up checklist
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-xs text-emerald-800/80">
|
<p className="text-xs text-emerald-800/80">
|
||||||
Upload anything that didn't flow through the system automatically. Reservations,
|
Upload anything that didn't flow through the system automatically. Reservations,
|
||||||
deposit invoicing, and client billing are handled outside the CRM — this checklist is for
|
deposit invoicing, and client billing are handled outside the CRM - this checklist is for
|
||||||
the paperwork that lives on the deal itself.
|
the paperwork that lives on the deal itself.
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ function ReminderFormBody({
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit reminder' : 'New reminder'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit reminder' : 'New reminder'}</SheetTitle>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Reminders are personal nudges — a follow-up call, a note to yourself, or something a
|
Reminders are personal nudges - a follow-up call, a note to yourself, or something a
|
||||||
teammate needs to action by a date. They show up in your dashboard, the daily digest
|
teammate needs to action by a date. They show up in your dashboard, the daily digest
|
||||||
email, and on whichever client / interest / berth you link them to.
|
email, and on whichever client / interest / berth you link them to.
|
||||||
</p>
|
</p>
|
||||||
@@ -282,7 +282,7 @@ function ReminderFormBody({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2fr/1fr split — the datetime-local control needs more room
|
{/* 2fr/1fr split - the datetime-local control needs more room
|
||||||
for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
|
for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
|
||||||
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
|
|||||||
without this, the console throws an a11y violation. */}
|
without this, the console throws an a11y violation. */}
|
||||||
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
|
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
|
||||||
|
|
||||||
{/* Drag handle — Vaul reads this as a swipe target. Centered grip
|
{/* Drag handle - Vaul reads this as a swipe target. Centered grip
|
||||||
+ a small label below feels iOS-native. */}
|
+ a small label below feels iOS-native. */}
|
||||||
<div className="flex flex-col items-center pt-2.5 pb-1.5">
|
<div className="flex flex-col items-center pt-2.5 pb-1.5">
|
||||||
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
|
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
|
||||||
@@ -374,7 +374,7 @@ function EmptyHint() {
|
|||||||
<Search className="size-7" aria-hidden />
|
<Search className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Search clients, yachts, interests, berths, invoices, documents — paste a UUID or invoice
|
Search clients, yachts, interests, berths, invoices, documents - paste a UUID or invoice
|
||||||
number to jump directly.
|
number to jump directly.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ function SendDocumentDialogInner({
|
|||||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The PDF file is added by the system after the body — your text won’t override
|
The PDF file is added by the system after the body - your text won’t override
|
||||||
it.
|
it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ export function WebsiteAnalyticsShell() {
|
|||||||
<NotConfiguredEmptyState portSlug={portSlug} />
|
<NotConfiguredEmptyState portSlug={portSlug} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Realtime panel — collapsible "what's happening RIGHT NOW"
|
{/* Realtime panel - collapsible "what's happening RIGHT NOW"
|
||||||
strip at the very top. Polling only fires while expanded. */}
|
strip at the very top. Polling only fires while expanded. */}
|
||||||
<RealtimePanel />
|
<RealtimePanel />
|
||||||
|
|
||||||
{/* Live indicator + KPI tiles — mirrors Umami's overview row. */}
|
{/* Live indicator + KPI tiles - mirrors Umami's overview row. */}
|
||||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||||
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
||||||
<KpiPair
|
<KpiPair
|
||||||
@@ -117,7 +117,7 @@ export function WebsiteAnalyticsShell() {
|
|||||||
value={stats.data?.data?.visitors}
|
value={stats.data?.data?.visitors}
|
||||||
prev={stats.data?.data?.comparison?.visitors}
|
prev={stats.data?.data?.comparison?.visitors}
|
||||||
accent="teal"
|
accent="teal"
|
||||||
tooltip="Unique people who visited the site at least once. Counted by anonymous device fingerprint — one person across two devices counts as two."
|
tooltip="Unique people who visited the site at least once. Counted by anonymous device fingerprint - one person across two devices counts as two."
|
||||||
/>
|
/>
|
||||||
<KpiPair
|
<KpiPair
|
||||||
label="Visits"
|
label="Visits"
|
||||||
@@ -175,7 +175,7 @@ export function WebsiteAnalyticsShell() {
|
|||||||
browsing five pages in one sitting still counts as 1 session.
|
browsing five pages in one sitting still counts as 1 session.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Pages-per-session (pageviews ÷ sessions) is a rough engagement signal — higher
|
Pages-per-session (pageviews ÷ sessions) is a rough engagement signal - higher
|
||||||
means people are exploring deeper.
|
means people are exploring deeper.
|
||||||
</p>
|
</p>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -257,14 +257,14 @@ export function WebsiteAnalyticsShell() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Engagement heatmap — full-width so the 7×24 grid has room
|
{/* Engagement heatmap - full-width so the 7×24 grid has room
|
||||||
to breathe and cells are large enough to hover comfortably. */}
|
to breathe and cells are large enough to hover comfortably. */}
|
||||||
<WeeklyHeatmap range={range} />
|
<WeeklyHeatmap range={range} />
|
||||||
|
|
||||||
{/* Recent sessions */}
|
{/* Recent sessions */}
|
||||||
<SessionsList range={range} />
|
<SessionsList range={range} />
|
||||||
|
|
||||||
{/* World heatmap — visitor counts per country (full-width, bottom of page) */}
|
{/* World heatmap - visitor counts per country (full-width, bottom of page) */}
|
||||||
<VisitorWorldMap
|
<VisitorWorldMap
|
||||||
rows={allCountries.data?.data ?? null}
|
rows={allCountries.data?.data ?? null}
|
||||||
loading={allCountries.isLoading}
|
loading={allCountries.isLoading}
|
||||||
@@ -405,7 +405,7 @@ function BounceRateTile({
|
|||||||
delta={delta}
|
delta={delta}
|
||||||
deltaSuffix="%"
|
deltaSuffix="%"
|
||||||
lowerIsBetter
|
lowerIsBetter
|
||||||
tooltip="Share of visits that ended without a second pageview — i.e. someone landed, didn't click anything, and left. Lower is generally better."
|
tooltip="Share of visits that ended without a second pageview - i.e. someone landed, didn't click anything, and left. Lower is generally better."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function WeeklyHeatmap({ range }: Props) {
|
|||||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||||
<p className="font-semibold text-foreground">When is your audience active?</p>
|
<p className="font-semibold text-foreground">When is your audience active?</p>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Each cell is one hour of one day — the columns are{' '}
|
Each cell is one hour of one day - the columns are{' '}
|
||||||
<strong>hours of the day in UTC</strong> (0 = midnight, 23 = 11 PM) and the rows are
|
<strong>hours of the day in UTC</strong> (0 = midnight, 23 = 11 PM) and the rows are
|
||||||
days of the week. Darker blue means more sessions started during that hour across
|
days of the week. Darker blue means more sessions started during that hour across
|
||||||
the whole selected period. Hover any cell for the exact session count.
|
the whole selected period. Hover any cell for the exact session count.
|
||||||
@@ -126,7 +126,7 @@ export function WeeklyHeatmap({ range }: Props) {
|
|||||||
{/* Legend + floating value indicator */}
|
{/* Legend + floating value indicator */}
|
||||||
<div className="mt-4 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
|
<div className="mt-4 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
Hour of day (UTC) — colour intensity scaled to peak ({max.toLocaleString()}{' '}
|
Hour of day (UTC) - colour intensity scaled to peak ({max.toLocaleString()}{' '}
|
||||||
sessions)
|
sessions)
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -147,7 +147,7 @@ export function WeeklyHeatmap({ range }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover tooltip — single element re-positioned via the
|
{/* Hover tooltip - single element re-positioned via the
|
||||||
hovered cell's data, much cheaper than mounting 168
|
hovered cell's data, much cheaper than mounting 168
|
||||||
Radix Tooltips. */}
|
Radix Tooltips. */}
|
||||||
{hover ? (
|
{hover ? (
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function YachtForm({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Dimensions — auto-convert ft ↔ m. Whichever unit the operator
|
{/* Dimensions - auto-convert ft ↔ m. Whichever unit the operator
|
||||||
types into, the other unit gets recomputed in place. We round
|
types into, the other unit gets recomputed in place. We round
|
||||||
the converted value to keep the input clean (2 decimal places),
|
the converted value to keep the input clean (2 decimal places),
|
||||||
and skip the recompute when the user is mid-edit on the same
|
and skip the recompute when the user is mid-edit on the same
|
||||||
@@ -285,7 +285,7 @@ export function YachtForm({
|
|||||||
Dimensions
|
Dimensions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground -mt-2">
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
Type a value in either ft or m — the other unit auto-fills.
|
Type a value in either ft or m - the other unit auto-fills.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<DimensionPair
|
<DimensionPair
|
||||||
|
|||||||
Reference in New Issue
Block a user