feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -76,7 +76,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>
For EOIs signed outside Documenso (paper, in person, alternate e-sign vendor). The
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). The
uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI
Signed.
</DialogDescription>

View File

@@ -18,7 +18,12 @@ import {
deriveInitials,
} from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
import {
stageBadgeClass,
stageDotClass,
stageLabel as toStageLabel,
formatSource,
} from '@/lib/constants';
import { computeUrgencyBadges } from '@/components/interests/urgency';
import type { InterestRow } from './interest-columns';
@@ -28,13 +33,6 @@ const CATEGORY_LABELS: Record<string, string> = {
hot_lead: 'Hot lead',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface InterestCardProps {
interest: InterestRow;
portSlug: string;
@@ -48,7 +46,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
const accentClass = stageDotClass(interest.pipelineStage);
const isHotLead = interest.leadCategory === 'hot_lead';
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
const sourceLabel = formatSource(interest.source);
const tags = interest.tags ?? [];
const notesCount = interest.notesCount ?? 0;
const urgencyBadges = computeUrgencyBadges(interest);

View File

@@ -36,6 +36,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
@@ -391,14 +392,47 @@ function ComposeDialog({
/>
</div>
<div className="space-y-1">
<Label htmlFor="cl-followup">Follow up by (optional creates a reminder)</Label>
<Input
id="cl-followup"
type="datetime-local"
value={followUpAt}
onChange={(e) => setFollowUpAt(e.target.value)}
/>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<label
className="flex items-center gap-2 text-sm font-medium cursor-pointer select-none"
htmlFor="cl-followup-toggle"
>
<Checkbox
id="cl-followup-toggle"
checked={!!followUpAt}
onCheckedChange={(v) => {
if (v) {
// Default to a week from now @ 09:00 local so reps get a
// usable cadence without having to type a date.
const d = new Date();
d.setDate(d.getDate() + 7);
d.setHours(9, 0, 0, 0);
const tz = d.getTimezoneOffset() * 60_000;
setFollowUpAt(new Date(d.getTime() - tz).toISOString().slice(0, 16));
} else {
setFollowUpAt('');
}
}}
/>
Add follow-up reminder?
</label>
{followUpAt ? (
<div className="space-y-1 pl-6">
<Label htmlFor="cl-followup" className="text-xs text-muted-foreground">
Remind me on
</Label>
<Input
id="cl-followup"
type="datetime-local"
value={followUpAt}
onChange={(e) => setFollowUpAt(e.target.value)}
className="max-w-xs"
/>
<p className="text-[11px] text-muted-foreground">
A reminder is created on this interest for the time above.
</p>
</div>
) : null}
</div>
</div>

View File

@@ -278,7 +278,7 @@ function ActiveContractCard({
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
The signing service hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
@@ -341,7 +341,7 @@ function EmptyContractState({
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled
externally, or upload the draft PDF and send for e-signing via Documenso.
externally, or upload the draft PDF and send for e-signing.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">

View File

@@ -33,6 +33,7 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
lost_other: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
};

View File

@@ -267,7 +267,7 @@ function ActiveEoiCard({
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
The signing service hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
@@ -329,7 +329,7 @@ function EmptyEoiState({
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing Documenso handles the signing chain. You can also
Generate the EOI to send it for signing the signing service handles the signing chain. You can also
upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,16 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
@@ -30,12 +40,13 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
import { cn } from '@/lib/utils';
const CATEGORY_LABELS: Record<string, string> = {
@@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [clientOpen, setClientOpen] = useState(false);
const [berthOpen, setBerthOpen] = useState(false);
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, isDirty },
} = useForm<CreateInterestInput>({
resolver: zodResolver(createInterestSchema),
defaultValues: {
@@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerthId = watch('berthId');
const selectedYachtId = watch('yachtId');
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
function requestClose() {
if (isDirty && !isSubmitting && !mutation.isPending) {
setDiscardConfirmOpen(true);
return;
}
onOpenChange(false);
}
// Fetch the selected client's company memberships so the YachtPicker can
// include yachts owned by companies the client belongs to (e.g. a
@@ -200,7 +220,16 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Sheet
open={open}
onOpenChange={(next) => {
if (next) {
onOpenChange(true);
return;
}
requestClose();
}}
>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
@@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Client *</Label>
<Popover open={clientOpen} onOpenChange={setClientOpen}>
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -231,8 +260,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
{/* shouldFilter={false}: server-side search via setClientSearch
drives the result set. Without this, cmdk's default filter
matches the user's typed text against CommandItem.value
(the client UUID) and silently drops every result that
doesn't contain the typed substring in its id. */}
<Command shouldFilter={false}>
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
<CommandList>
<CommandEmpty>
@@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -284,8 +318,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
<Command shouldFilter={false}>
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
<CommandList>
<CommandEmpty>
@@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
{SOURCES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator />
{/* Desired berth dimensions (recommender inputs) */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Berth size desired
</h3>
<p className="text-xs text-muted-foreground">
Imperial. Optional - the recommender treats blank fields as no constraint on that
axis.
</p>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="desiredLengthFt">Length (ft)</Label>
<Input
id="desiredLengthFt"
{...register('desiredLengthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 60"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredWidthFt">Width (ft)</Label>
<Input
id="desiredWidthFt"
{...register('desiredWidthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 18"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredDraftFt">Draft (ft)</Label>
<Input
id="desiredDraftFt"
{...register('desiredDraftFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 6"
/>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Berth size desired
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Optional - the recommender treats blank fields as no constraint on that axis.
</p>
</div>
<UnitToggle value={desiredUnit} onChange={setDesiredUnit} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DimensionInput
htmlId="desiredLengthFt"
label="Length"
placeholder={desiredUnit === 'ft' ? 'e.g. 60' : 'e.g. 18.29'}
unit={desiredUnit}
ftValue={watch('desiredLengthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredLengthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredWidthFt"
label="Width"
placeholder={desiredUnit === 'ft' ? 'e.g. 18' : 'e.g. 5.49'}
unit={desiredUnit}
ftValue={watch('desiredWidthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredWidthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredDraftFt"
label="Draft"
placeholder={desiredUnit === 'ft' ? 'e.g. 6' : 'e.g. 1.83'}
unit={desiredUnit}
ftValue={watch('desiredDraftFt') as string | undefined}
onChangeFt={(v) => setValue('desiredDraftFt', v, { shouldDirty: true })}
/>
</div>
</div>
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
{reminderEnabled && (
<div className="space-y-1">
<Label>Reminder Days</Label>
<Input
{...register('reminderDays', { valueAsNumber: true })}
type="number"
min={1}
placeholder="e.g. 7"
<Label htmlFor="reminderDays">Reminder cadence</Label>
<ReminderDaysInput
id="reminderDays"
value={watch('reminderDays') ?? null}
onChange={(v) => setValue('reminderDays', v ?? undefined)}
/>
</div>
)}
@@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" variant="outline" onClick={requestClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
@@ -537,6 +566,29 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button>
</SheetFooter>
</form>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ve filled in some fields. Closing now will lose them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
onOpenChange(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetContent>
{createYachtOpen && selectedClientId && (
<YachtForm
@@ -548,3 +600,140 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Sheet>
);
}
// ── Helpers for the "Berth size desired" section ──────────────────────────────
const FT_PER_M = 1 / 0.3048;
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) {
return (
<div
className="inline-flex rounded-md border bg-muted/30 p-0.5 text-xs"
role="radiogroup"
aria-label="Display unit"
>
{(['ft', 'm'] as const).map((u) => (
<button
key={u}
type="button"
role="radio"
aria-checked={value === u}
onClick={() => onChange(u)}
className={cn(
'h-7 rounded px-3 font-medium transition-colors',
value === u
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{u}
</button>
))}
</div>
);
}
interface DimensionInputProps {
htmlId: string;
label: string;
placeholder?: string;
unit: 'ft' | 'm';
ftValue: string | number | undefined;
onChangeFt: (next: string | undefined) => void;
}
/**
* Single dimension input bound to a form value stored in feet. Renders the
* value in the rep's chosen display unit and converts back on edit. The form
* state stays canonical ft so the recommender (which queries `b.length_ft`
* etc.) sees the same number regardless of which unit the rep typed in.
*
* Local `display` state preserves mid-typing strings like "18." that would
* otherwise be lost to round-tripping through Number().
*/
function DimensionInput({
htmlId,
label,
placeholder,
unit,
ftValue,
onChangeFt,
}: DimensionInputProps) {
const focusedRef = useRef(false);
const [display, setDisplay] = useState<string>(() => computeDisplay(ftValue, unit));
// Re-sync from the canonical ft value when it changes externally (form
// reset, unit toggle). Skip while focused so we don't fight keystrokes.
useEffect(() => {
if (focusedRef.current) return;
setDisplay(computeDisplay(ftValue, unit));
}, [ftValue, unit]);
const altValue = computeAltDisplay(ftValue, unit);
return (
<div className="space-y-1">
<Label htmlFor={htmlId}>
{label} <span className="text-muted-foreground">({unit})</span>
</Label>
<Input
id={htmlId}
type="number"
inputMode="decimal"
step="0.01"
min={0}
placeholder={placeholder}
value={display}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
// Canonicalize the display from the ft source-of-truth on blur so
// any mid-typed garbage clears.
setDisplay(computeDisplay(ftValue, unit));
}}
onChange={(e) => {
const raw = e.target.value;
setDisplay(raw);
if (raw === '') {
onChangeFt(undefined);
return;
}
const n = parseFloat(raw);
if (!Number.isFinite(n) || n <= 0) {
onChangeFt(undefined);
return;
}
const ft = unit === 'ft' ? n : n * FT_PER_M;
onChangeFt(String(round2(ft)));
}}
/>
{altValue ? (
<p className="text-[11px] leading-tight text-muted-foreground"> {altValue}</p>
) : null}
</div>
);
}
function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string {
if (ftValue === undefined || ftValue === null || ftValue === '') return '';
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft)) return '';
const v = unit === 'ft' ? ft : ft * 0.3048;
return String(round2(v));
}
function computeAltDisplay(
ftValue: string | number | undefined,
unit: 'ft' | 'm',
): string | null {
if (ftValue === undefined || ftValue === null || ftValue === '') return null;
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft) || ft <= 0) return null;
return unit === 'ft' ? `${round2(ft * 0.3048)} m` : `${round2(ft)} ft`;
}

View File

@@ -33,6 +33,7 @@ import {
} from '@/components/interests/interest-columns';
import { ColumnPicker } from '@/components/shared/column-picker';
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { InterestCard } from '@/components/interests/interest-card';
import { StageLegend } from '@/components/interests/stage-legend';
@@ -72,6 +73,7 @@ export function InterestList() {
}, [viewMode, setViewMode]);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);

View File

@@ -29,6 +29,7 @@ const OUTCOME_LABELS: Record<InterestOutcome, string> = {
lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost - no response',
lost_other: 'Lost - other',
cancelled: 'Cancelled',
};
@@ -36,6 +37,7 @@ const LOST_OUTCOMES: InterestOutcome[] = [
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
];

View File

@@ -64,7 +64,7 @@ export function InterestPicker({
})();
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"

View File

@@ -281,7 +281,7 @@ function ActiveReservationCard({
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
The signing service hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
@@ -344,7 +344,7 @@ function EmptyReservationState({
</h2>
<p className="mt-1 text-sm text-muted-foreground">
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you
handled externally, or upload the draft PDF and send for e-signing via Documenso.
handled externally, or upload the draft PDF and send for e-signing.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">

View File

@@ -9,6 +9,8 @@ import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
@@ -20,6 +22,7 @@ import { InterestDocumentsTab } from '@/components/interests/interest-documents-
import {
LEAD_CATEGORIES,
PIPELINE_STAGES,
SOURCES,
canTransitionStage,
type PipelineStage,
} from '@/lib/constants';
@@ -111,14 +114,17 @@ function useStageMutation(interestId: string) {
stage,
reason,
override,
milestoneDate,
}: {
stage: string;
reason?: string;
override?: boolean;
/** Optional ISO date for the milestone column (instead of "now"). */
milestoneDate?: string;
}) =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: stage, reason, override },
body: { pipelineStage: stage, reason, override, milestoneDate },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] });
@@ -173,7 +179,7 @@ interface MilestoneSectionProps {
hideAutoButton?: boolean;
}>;
status: string | null;
onAdvance: (stage: string) => void;
onAdvance: (stage: string, milestoneDate?: string) => void;
isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has
* moved past their advanceStage even if the date stamp is missing - e.g.
@@ -196,6 +202,87 @@ interface MilestoneSectionProps {
* (Documenso webhook, paid invoice → deposit, etc.), they patch the same
* stage endpoint and these checkmarks light up automatically.
*/
/**
* Button that opens a date-picker popover before advancing a milestone. The
* default is today, but the rep can back-date the event (e.g. "deposit
* landed yesterday") so the stamped milestone column reflects the real date
* rather than the click time.
*/
function MilestoneAdvanceButton({
label,
variant,
disabled,
onConfirm,
}: {
label: string;
variant: 'default' | 'outline' | 'ghostLink';
disabled?: boolean;
onConfirm: (milestoneDate: string) => void;
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState<string>(() => new Date().toISOString().slice(0, 10));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{variant === 'ghostLink' ? (
<button
type="button"
disabled={disabled}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{label}
</button>
) : (
<Button
type="button"
variant={variant}
size="sm"
disabled={disabled}
className="mt-2 h-7 px-2.5 text-xs"
>
{label}
</Button>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 space-y-2 p-3">
<div className="space-y-1">
<label className="text-xs font-medium" htmlFor="milestone-date">
Date completed
</label>
<Input
id="milestone-date"
type="date"
value={date}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setDate(e.target.value)}
className="h-9"
/>
<p className="text-[11px] text-muted-foreground">
Defaults to today back-date if the event happened earlier.
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={!date || disabled}
onClick={() => {
onConfirm(date);
setOpen(false);
}}
>
Confirm
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function MilestoneSection({
title,
icon: Icon,
@@ -282,16 +369,12 @@ function MilestoneSection({
) : null}
</div>
{isNext && step.advanceStage && !step.hideAutoButton ? (
<Button
type="button"
<MilestoneAdvanceButton
label={step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
variant={isActive ? 'default' : 'outline'}
size="sm"
disabled={isPending}
onClick={() => onAdvance(step.advanceStage!)}
className="mt-2 h-7 px-2.5 text-xs"
>
{step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
</Button>
onConfirm={(date) => onAdvance(step.advanceStage!, date)}
/>
) : null}
</div>
</li>
@@ -392,7 +475,7 @@ function OverviewTab({
* skip-ahead pattern from the inline stage picker so audit trails
* stay consistent regardless of which surface the rep used.
*/
const advance = (stage: string) => {
const advance = (stage: string, milestoneDate?: string) => {
const fromStage = interest.pipelineStage as PipelineStage;
const toStage = stage as PipelineStage;
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
@@ -409,6 +492,7 @@ function OverviewTab({
stage,
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
override: isOverride || undefined,
milestoneDate,
});
};
@@ -566,14 +650,12 @@ function OverviewTab({
Create deposit invoice
</Link>
</Button>
<button
type="button"
onClick={() => advance('deposit_10pct')}
<MilestoneAdvanceButton
label="Mark received manually"
variant="ghostLink"
disabled={stageMutation.isPending}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Mark received manually
</button>
onConfirm={(date) => advance('deposit_10pct', date)}
/>
</div>
) : null,
pastSummary: interest.dateDepositReceived
@@ -682,7 +764,12 @@ function OverviewTab({
/>
</EditableRow>
<EditableRow label="Source">
<InlineEditableField value={interest.source} onSave={save('source')} />
<InlineEditableField
variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source}
onSave={save('source')}
/>
</EditableRow>
</dl>
</div>

View File

@@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
]);

View File

@@ -36,6 +36,13 @@ import { Label } from '@/components/ui/label';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { HelpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
@@ -303,53 +310,96 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1">
{/* Switch sits next to its label (gap-2.5) instead of being
flexed to the far right via justify-between — when the
column is wide, justify-between created a confusing visual
gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch
id={`specific-${row.berthId}`}
checked={row.isSpecificInterest}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
/>
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
<TooltipProvider delayDuration={200}>
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1">
{/* Switch sits next to its label (gap-2.5) instead of being
flexed to the far right via justify-between — when the
column is wide, justify-between created a confusing visual
gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch
id={`specific-${row.berthId}`}
checked={row.isSpecificInterest}
disabled={isPending}
onCheckedChange={(checked) =>
onUpdate(row.berthId, { isSpecificInterest: checked })
}
/>
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Specifically pitching do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Mark this berth as one your client is actively considering. When on, the berth
appears as <strong>Under Offer</strong> on the public map and counts toward the
recommender&apos;s &quot;heat&quot; score. Turn off if the link is legal/EOI-only.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<Switch
id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
Mark in EOI bundle
</Label>
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<Switch
id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
<Label
htmlFor={`bundle-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Mark in EOI bundle
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Mark in EOI bundle do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle
? 'Covered by the interests EOI signature.'
: 'Not covered by the EOI bundle.'}
</p>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle
? 'Covered by the interests EOI signature.'
: 'Not covered by the EOI bundle.'}
</p>
</div>
</div>
</TooltipProvider>
{showBypassControl ? (
<div className="mt-3 flex flex-wrap items-start justify-between gap-2 border-t pt-3">
<div className="min-w-0 space-y-0.5">
// Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't
// float far-right while the toggles above are anchored left.
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Bypass EOI for this berth</p>
{row.eoiBypassReason ? (
<p className="text-xs text-muted-foreground">