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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ function ActiveContractCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn'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">
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ function ActiveEoiCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn'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">
|
||||
|
||||
@@ -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'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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function InterestPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -281,7 +281,7 @@ function ActiveReservationCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn'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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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's "heat" 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's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's
|
||||
<strong> Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
to keep the link without legal coverage.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s EOI signature.'
|
||||
: 'Not covered by the EOI bundle.'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s 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">
|
||||
|
||||
Reference in New Issue
Block a user