feat(uat-batch): Group A quick-fixes — 7 items shipped, 5 verified pre-shipped

Sweeps Group A of the 2026-05-21 remaining-plan. Several items the
plan listed as open turned out to already be shipped (annotation gap
in the master doc) — those are confirmed in the commit notes.

Shipped now:
  A1  Documenso settings: collapsed `V2_FEATURE_FIELDS` +
      `CONTRACT_RESERVATION_FIELDS` (legacy SettingsFormCard) into
      `RegistryDrivenForm` sections (`documenso.behavior` +
      `documenso.templates`). Every Documenso setting now flows
      through the registry path that surfaces the env-fallback /
      port / global source badge per field. EOI generation card
      retitled to "Templates & signing pathway" since it now covers
      EOI + reservation + contract template IDs (registry already
      had all three under `documenso.templates`).
  A2  WatchersCard empty state: bumped `mb-3 → mb-4 pb-1` so the
      "No one is watching yet" line has breathing room above the
      "Add a watcher…" select.
  A4  /invoices/upload-receipts guide copy: terse luxury-CRM tone.
      Drop "Snap a photo", "fancy phone camera", "No typing. No
      spreadsheets." Tighten OCR explainer to one sentence;
      action-oriented step + best-practices headers.
  A5  Pageviews chart X-axis: added `interval="preserveStartEnd"` +
      `minTickGap={52}` so multi-week ranges thin out the middle
      ticks instead of overlapping. The MM-DD formatter was already
      in place from an earlier session.
  A7  Inbox doc comment: was stale ("Alerts first, Reminders
      second") but the JSX already had Reminders before Alerts.
      Fixed the docstring.
  A9  CommandList scroll-cap: `max-h-[300px]` now `max-h-[min(300px,
      var(--radix-popover-content-available-height,300px))]` so the
      cmdk list never extends past the host Popover's available
      area. Non-Popover hosts fall through to the 300px static cap.
  A10 DropdownMenuContent: `max-h-96` now
      `max-h-[min(24rem,var(--radix-dropdown-menu-content-
      available-height,24rem))]` for the same available-space
      behaviour on long menus near the viewport edge.
  A11 Residential InterestsTab (list page): row gets an onClick →
      `router.push`; first-cell Link stops propagation so middle-
      click / Cmd-click "open in new tab" still works.
  A12 StageStepper: gained a stage-name row below the bar showing
      every reached stage's short label inline (muted for future
      stages). `size="xs"` variant keeps the cramped table-cell
      footprint intact (no labels).

Already shipped (just annotation gap in master doc):
  A3  EOI "Mark as signed without file" button — line 599 of
      interest-eoi-tab.tsx, parent passes onMarkSigned. Master doc
      already has `SHIPPED in 52342ee` annotation.
  A6  Pageviews vs Sessions explainer — Info popover at line
      157-181 of website-analytics-shell.tsx.
  A8  BulkAddBerthsWizard CurrencySelect — line 376 (apply-to-all)
      + line 456 (per-row).

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 21:34:20 +02:00
parent a555798cfe
commit e33313bd64
10 changed files with 148 additions and 160 deletions

View File

@@ -1,9 +1,5 @@
import { CheckCircle2, Info } from 'lucide-react'; import { CheckCircle2, Info } from 'lucide-react';
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form'; import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button'; import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card'; import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
@@ -11,57 +7,11 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// API_FIELDS removed — replaced by <RegistryDrivenForm sections={['documenso.api']} /> // All field arrays removed — every Documenso setting now flows through
// which adds the new webhook-secret field + AES encrypts the API key at rest. // `RegistryDrivenForm`, which surfaces the env-fallback / port / global
// source badge on each field. The settings themselves live in
const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [ // `src/lib/settings/registry.ts` under sections `documenso.api` /
{ // `.signers` / `.templates` / `.behavior`.
key: 'documenso_contract_template_id',
label: 'Contract Documenso template ID (optional)',
description:
'Numeric template ID for sales contract generation. Leave blank to use the per-interest upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
type: 'string',
placeholder: '',
defaultValue: '',
},
{
key: 'documenso_reservation_template_id',
label: 'Reservation agreement Documenso template ID (optional)',
description:
'Numeric template ID for reservation agreements. Same logic - leave blank to upload per interest.',
type: 'string',
placeholder: '',
defaultValue: '',
},
];
// Embedded signing field config + Test + Setup help all live inside
// `<EmbeddedSigningCard />` (imported above). Kept out of the field list
// here so the admin page reads as a flat sequence of cards.
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
{
key: 'documenso_signing_order',
label: 'Signing order',
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.',
type: 'select',
options: [
{ value: 'PARALLEL', label: 'PARALLEL - all signers invited at once' },
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL - one at a time in order' },
],
defaultValue: 'PARALLEL',
},
{
key: 'documenso_redirect_url',
label: 'Post-signing redirect URL',
description:
"URL Documenso redirects the signer to after they complete signing. Typically the marketing site's success page so signers land on a branded thank-you rather than Documenso's own page. Leave blank to use Documenso's default. v1 and v2 both honour this. Example: https://portnimara.com/sign/success",
type: 'string',
placeholder: 'https://portnimara.com/sign/success',
defaultValue: '',
},
];
export default function DocumensoSettingsPage() { export default function DocumensoSettingsPage() {
return ( return (
@@ -235,10 +185,10 @@ export default function DocumensoSettingsPage() {
extra={<DocumensoTestButton />} extra={<DocumensoTestButton />}
/> />
<SettingsFormCard <RegistryDrivenForm
title="v2 signing behaviour" sections={['documenso.behavior']}
title="Signing behaviour"
description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances." description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances."
fields={V2_FEATURE_FIELDS}
/> />
<RegistryDrivenForm <RegistryDrivenForm
@@ -249,17 +199,11 @@ export default function DocumensoSettingsPage() {
<RegistryDrivenForm <RegistryDrivenForm
sections={['documenso.templates']} sections={['documenso.templates']}
title="EOI generation" title="Templates & signing pathway"
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 IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
extra={<TemplateSyncButton />} extra={<TemplateSyncButton />}
/> />
<SettingsFormCard
title="Contract & reservation templates (optional)"
description="Most ports leave these blank because contracts/reservations are drafted per interest and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
fields={CONTRACT_RESERVATION_FIELDS}
/>
<EmbeddedSigningCard /> <EmbeddedSigningCard />
</div> </div>
); );

View File

@@ -15,6 +15,7 @@ import {
STAGE_BADGE, STAGE_BADGE,
STAGE_DOT, STAGE_DOT,
STAGE_LABELS, STAGE_LABELS,
STAGE_SHORT_LABELS,
safeStage, safeStage,
type PipelineStage, type PipelineStage,
} from '@/components/clients/pipeline-constants'; } from '@/components/clients/pipeline-constants';
@@ -54,29 +55,54 @@ export function StageStepper({
// micro-dots that vanish under cramped layouts. // micro-dots that vanish under cramped layouts.
const height = size === 'xs' ? 'h-1' : 'h-1.5'; const height = size === 'xs' ? 'h-1' : 'h-1.5';
return ( return (
<div <div className="flex w-full flex-col gap-1">
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)} <div
role="progressbar" className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
aria-label="Pipeline progress" role="progressbar"
aria-valuenow={idx + 1} aria-label="Pipeline progress"
aria-valuemin={1} aria-valuenow={idx + 1}
aria-valuemax={PIPELINE_STAGES.length} aria-valuemin={1}
> aria-valuemax={PIPELINE_STAGES.length}
{PIPELINE_STAGES.map((stage, i) => { >
const isReached = i <= idx; {PIPELINE_STAGES.map((stage, i) => {
const isCurrent = i === idx; const isReached = i <= idx;
return ( const isCurrent = i === idx;
<div return (
key={stage} <div
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`} key={stage}
className={cn( title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
'flex-1 transition-colors', className={cn(
isReached ? STAGE_DOT[stage] : 'bg-transparent', 'flex-1 transition-colors',
i > 0 ? 'border-l border-card' : '', isReached ? STAGE_DOT[stage] : 'bg-transparent',
)} i > 0 ? 'border-l border-card' : '',
/> )}
); />
})} );
})}
</div>
{/* Stage-name row below the bar — surfaces all reached stage names
inline (compact short-labels) so the bar isn't a mystery without
hovering. Future stages render in muted text so the rep can still
see the ladder ahead. The `xs` size variant hides this row to
keep the cramped table-cell footprint intact. */}
{size !== 'xs' ? (
<div className="flex w-full text-[10px] font-medium uppercase tracking-wide">
{PIPELINE_STAGES.map((stage, i) => {
const isReached = i <= idx;
return (
<div
key={stage}
className={cn(
'flex-1 truncate text-center',
isReached ? 'text-foreground' : 'text-muted-foreground/60',
)}
>
{STAGE_SHORT_LABELS[stage]}
</div>
);
})}
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
export { export {
PIPELINE_STAGES, PIPELINE_STAGES,
STAGE_LABELS, STAGE_LABELS,
STAGE_SHORT_LABELS,
STAGE_BADGE, STAGE_BADGE,
STAGE_DOT, STAGE_DOT,
STAGE_WEIGHTS, STAGE_WEIGHTS,

View File

@@ -584,7 +584,12 @@ function WatchersCard({ documentId, watchers }: { documentId: string; watchers:
</p> </p>
{watchers.length === 0 ? ( {watchers.length === 0 ? (
<p className="mb-3 text-xs text-muted-foreground">No one is watching this document yet.</p> // Larger bottom spacing (pb-1 + mb-4) gives the empty-state row enough
// breathing room above the "Add a watcher…" select — the prior `mb-3`
// alone left the two lines stacked tight against each other.
<p className="mb-4 pb-1 text-xs text-muted-foreground">
No one is watching this document yet.
</p>
) : ( ) : (
<ul className="mb-3 space-y-1"> <ul className="mb-3 space-y-1">
{watchers.map((w) => { {watchers.map((w) => {

View File

@@ -11,7 +11,7 @@ import { useAlertCount } from '@/components/alerts/use-alerts';
/** /**
* Merged "Inbox" surface — replaces the previously-separate /alerts and * Merged "Inbox" surface — replaces the previously-separate /alerts and
* /reminders pages. Two stacked sections (Alerts first, Reminders second) * /reminders pages. Two stacked sections (Reminders first, Alerts second)
* preserve the source distinction (system-flagged vs user-set) while * preserve the source distinction (system-flagged vs user-set) while
* giving reps a single "things demanding my attention" surface. * giving reps a single "things demanding my attention" surface.
* *

View File

@@ -26,9 +26,9 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="How to upload receipts for reimbursement" title="Receipt upload"
eyebrow="Business expenses" eyebrow="Business expenses"
description="When you spend your own money on a business expense for the marina, use this to log it. Snap a photo of the receipt with your phone, the system reads it for you, and finance approves it on the parent company's side." description="Capture an out-of-pocket business expense by photographing the receipt. OCR extracts vendor, date, total, and currency; finance reviews and reimburses."
variant="gradient" variant="gradient"
actions={ actions={
<Button asChild> <Button asChild>
@@ -41,27 +41,22 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
} }
/> />
{/* What it does, in plain English */} {/* What it does */}
<section className="rounded-xl border border-border bg-card p-5 shadow-xs"> <section className="rounded-xl border border-border bg-card p-5 shadow-xs">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
<Sparkles className="h-5 w-5" aria-hidden /> <Sparkles className="h-5 w-5" aria-hidden />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-base font-semibold">What does it actually do?</h2> <h2 className="text-base font-semibold">What it does</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
You paid out of pocket for something the marina needs (fuel, hardware, a part run, Photograph the receipt; the scanner extracts the vendor, date, total, and currency
lunch with a broker). Snap a photo of the receipt and this tool turns it into a into a pre-filled expense record. Finance reviews and reimburses from the parent
reimbursement request. It pulls out the vendor, the date, the total, and the currency, company.
drops them into the expense form, and queues the whole thing for the parent
company&apos;s finance team to approve and pay you back.
</p> </p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
The behind-the-scenes part is called OCR (short for &ldquo;optical character Extraction uses on-device OCR with AI-assisted field detection. Typical turnaround
recognition&rdquo;). Think of it as a fancy phone camera that knows how to read from photograph to saved record is under ten seconds.
printed text. Combined with a bit of AI to figure out which number is the total and
which is the tax, it turns a paper receipt into a ready-to-save expense in about five
seconds. No typing. No spreadsheets. No chasing finance for the form.
</p> </p>
</div> </div>
</div> </div>
@@ -72,12 +67,12 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
<Step <Step
number={1} number={1}
icon={<Smartphone className="h-5 w-5" />} icon={<Smartphone className="h-5 w-5" />}
title="Add the scanner to your phone" title="Install the scanner"
description="One-time setup. After this, the scanner opens like a normal app from your home screen." description="One-time setup. The scanner then opens from the home screen like any native app."
> >
<PlatformBlock <PlatformBlock
icon={<Apple className="h-4 w-4" />} icon={<Apple className="h-4 w-4" />}
label="iPhone or iPad (Safari)" label="iPhone / iPad (Safari)"
steps={[ steps={[
<> <>
Open{' '} Open{' '}
@@ -85,20 +80,20 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
href={scannerUrl as never} href={scannerUrl as never}
className="text-brand underline-offset-2 hover:underline" className="text-brand underline-offset-2 hover:underline"
> >
this link the scanner
</Link>{' '} </Link>{' '}
in Safari on your phone. in Safari.
</>, </>,
'Tap the Share button at the bottom of the screen (the square with the arrow pointing up).', 'Tap the Share icon.',
<> <>
Scroll down and tap <span className="font-medium">Add to Home Screen</span>. Select <span className="font-medium">Add to Home Screen</span>.
</>, </>,
'Confirm the name "Scanner" and tap Add. The icon now sits on your home screen.', 'Confirm to install.',
]} ]}
/> />
<PlatformBlock <PlatformBlock
icon={<Globe className="h-4 w-4" />} icon={<Globe className="h-4 w-4" />}
label="Android phone (Chrome)" label="Android (Chrome)"
steps={[ steps={[
<> <>
Open{' '} Open{' '}
@@ -106,16 +101,15 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
href={scannerUrl as never} href={scannerUrl as never}
className="text-brand underline-offset-2 hover:underline" className="text-brand underline-offset-2 hover:underline"
> >
this link the scanner
</Link>{' '} </Link>{' '}
in Chrome on your phone. in Chrome.
</>, </>,
'Tap the three-dot menu in the top-right corner.', 'Open the three-dot menu.',
<> <>
Tap <span className="font-medium">Install app</span> (older versions of Chrome say{' '} Select <span className="font-medium">Install app</span>.
<span className="font-medium">Add to Home screen</span>).
</>, </>,
'Confirm to install. The icon now sits on your home screen.', 'Confirm to install.',
]} ]}
/> />
</Step> </Step>
@@ -123,63 +117,54 @@ export function UploadReceiptsGuide({ portSlug }: Props) {
<Step <Step
number={2} number={2}
icon={<Camera className="h-5 w-5" />} icon={<Camera className="h-5 w-5" />}
title="Snap a photo of a receipt" title="Capture a receipt"
description="Open the scanner from your home screen and follow the prompts. The whole thing takes about ten seconds." description="Launch the scanner and follow the on-screen prompts."
> >
<ol className="space-y-3 pl-4 text-sm text-muted-foreground list-decimal"> <ol className="space-y-3 pl-4 text-sm text-muted-foreground list-decimal">
<li> <li>
<span className="font-medium text-foreground">Tap the camera tile.</span> Your phone <span className="font-medium text-foreground">Tap the camera tile.</span> Centre the
opens its camera. Hold the receipt flat, get the whole thing in the frame, and snap. receipt flat and within the frame; capture.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Wait a few seconds.</span> The system <span className="font-medium text-foreground">Review the extracted fields.</span>{' '}
reads the receipt and fills in the merchant, date, total, and currency for you. A Vendor, date, total, and currency populate automatically. Correct any field by tapping
loading spinner shows while this happens. it; category is the field most often set manually.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Glance over the numbers.</span> Most of <span className="font-medium text-foreground">Save.</span> The record lands as a
the time everything is correct. If something looks off (wrong total, wrong category), pending expense for finance review on the{' '}
tap the field and fix it. The category is the field you most often need to set
yourself.
</li>
<li>
<span className="font-medium text-foreground">Tap Save.</span> The receipt becomes a
pending expense ready for reimbursement. The parent company&apos;s finance team will
review it on the{' '}
<Link <Link
href={`/${portSlug}/expenses` as never} href={`/${portSlug}/expenses` as never}
className="text-brand underline-offset-2 hover:underline" className="text-brand underline-offset-2 hover:underline"
> >
Expenses page Expenses page
</Link>{' '} </Link>
and approve it for payback. You can check the status of any expense you submitted from . Submission status is visible from the same view.
there too.
</li> </li>
</ol> </ol>
</Step> </Step>
</div> </div>
{/* Tips */} {/* Best practices */}
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-5"> <div className="rounded-xl border border-dashed border-border bg-muted/30 p-5">
<h3 className="text-sm font-semibold">Tips for the best results</h3> <h3 className="text-sm font-semibold">Best practices</h3>
<ul className="mt-2 space-y-1.5 text-sm text-muted-foreground list-disc pl-5"> <ul className="mt-2 space-y-1.5 text-sm text-muted-foreground list-disc pl-5">
<li> <li>
Get the whole receipt in the frame. If the edges are cut off, the total or date might be Frame the entire receipt. Cropped edges can drop the total or date and trigger a finance
missed and finance might bounce it back to you. review.
</li> </li>
<li>Hold the camera steady. Blurry photos are harder to read. Retake if needed.</li> <li>Hold steady; bright, even lighting yields the most reliable extraction.</li>
<li> <li>
Receipts in foreign currencies are fine. The scanner picks up the currency code if it is Foreign currencies are supported when the currency code is printed on the slip;
printed on the slip. The parent company handles the conversion when they reimburse you. conversion is handled at reimbursement.
</li>
<li>If the camera looks dim, just turn on a light. Bright, even lighting works best.</li>
<li>
Add a quick note in the description if the expense needs context (who you met, what the
part was for, etc.). Saves finance from having to ask.
</li> </li>
<li> <li>
Lost the home-screen icon? Open this page on your phone again and tap the{' '} Add context in the description field where relevant (counterparty, purpose) to
<span className="font-medium">Open scanner</span> button at the top. accelerate finance approval.
</li>
<li>
Lost the home-screen shortcut? Reopen this page on the device and select{' '}
<span className="font-medium">Open scanner</span>.
</li> </li>
</ul> </ul>
</div> </div>
@@ -241,7 +226,7 @@ function PlatformBlock({
</ol> </ol>
<div className="mt-2 flex items-center text-xs text-muted-foreground/80"> <div className="mt-2 flex items-center text-xs text-muted-foreground/80">
<ArrowRight className="mr-1 h-3 w-3" aria-hidden /> <ArrowRight className="mr-1 h-3 w-3" aria-hidden />
Done. The scanner now opens from your home screen like a normal app. The scanner now launches from the home screen.
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -46,6 +46,7 @@ const STAGE_LABELS: Record<string, string> = {
export function ResidentialInterestsList() { export function ResidentialInterestsList() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const router = useRouter();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [stage, setStage] = useState<string>('all'); const [stage, setStage] = useState<string>('all');
@@ -125,12 +126,21 @@ export function ResidentialInterestsList() {
<tr <tr
key={i.id} key={i.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer" className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
// Whole-row navigation — clicking anywhere on the row opens
// the interest detail. The first-cell Link still works for
// middle-click / Cmd+click "open in new tab" + keyboard
// navigation; the row onClick covers the common case where
// reps click on Preferences / Notes columns.
onClick={() => router.push(`/${portSlug}/residential/interests/${i.id}` as never)}
> >
<td className="px-3 py-2"> <td className="px-3 py-2">
<Link <Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${i.id}` as any} href={`/${portSlug}/residential/interests/${i.id}` as any}
className="font-medium hover:underline" className="font-medium hover:underline"
// Don't fire the row's onClick when the rep middle-clicks
// the link — the Link's native handler covers that path.
onClick={(e) => e.stopPropagation()}
> >
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</Link> </Link>

View File

@@ -64,7 +64,16 @@ const CommandList = React.forwardRef<
// event ourselves so the list scrolls regardless of focus state. // event ourselves so the list scrolls regardless of focus state.
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden overscroll-contain', className)} // Cap the list at whichever is smaller: the static 300px ceiling or the
// remaining viewport space the parent Popover has below its trigger.
// Without the radix-popover variable, long lists in a Popover scrolled
// past the bottom of the popover content (the visible bottom edge was
// clipped by the popover's auto-sizing). The variable resolves to
// `<undefined>` for non-Popover hosts; the static max-h still applies.
className={cn(
'max-h-[min(300px,var(--radix-popover-content-available-height,300px))] overflow-y-auto overflow-x-hidden overscroll-contain',
className,
)}
onWheel={(event) => { onWheel={(event) => {
onWheel?.(event); onWheel?.(event);
if (event.defaultPrevented) return; if (event.defaultPrevented) return;

View File

@@ -63,10 +63,13 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
// Cap at 24rem (384px) so long menus don't visually stretch // Cap at the smaller of 24rem (384px) and the radix-reported
// edge-to-edge of the viewport — internal scroll handles // available height under/above the trigger, so long menus don't
// overflow. Consumers can override via the `className` prop. // visually stretch past the viewport edge on small screens —
'z-50 max-h-96 min-w-32 overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md', // internal scroll handles overflow. The CSS variable is set by
// Radix on `[data-side]` collision detection. Consumers can
// override via the `className` prop.
'z-50 max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height,24rem))] min-w-32 overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)',
className, className,
)} )}

View File

@@ -68,6 +68,11 @@ export function PageviewsChart({ data }: Props) {
fontSize={11} fontSize={11}
tick={{ fill: 'hsl(var(--muted-foreground))' }} tick={{ fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={formatXTick} tickFormatter={formatXTick}
// Anchor first + last ticks then let Recharts thin out the middle —
// multi-week ranges previously crowded every day-bucket label onto
// the axis. minTickGap enforces ~52px between rendered ticks.
interval="preserveStartEnd"
minTickGap={52}
/> />
<YAxis <YAxis
fontSize={11} fontSize={11}