Files
pn-new-crm/src/components/layout/mobile/mobile-bottom-tabs.tsx
Matt 3ffee79f3f 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>
2026-05-12 14:50:58 +02:00

128 lines
3.8 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
type TabSpec = {
label: string;
icon: typeof LayoutDashboard;
segment: string; // route segment after /[portSlug]/
};
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
// Search occupies the center slot. Documents demoted to the MoreSheet —
// reps reach docs less often than berths during a walking inventory check,
// and pinned-to-client documents are accessed via the client detail anyway.
const TABS_LEFT: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Clients', icon: Users, segment: 'clients' },
];
const TABS_RIGHT: TabSpec[] = [
{ label: 'Berths', icon: Anchor, segment: 'berths' },
];
interface MobileBottomTabsProps {
onMoreClick: () => void;
onSearchClick: () => void;
}
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
function isActive(segment: string): boolean {
return pathname.startsWith(`/${portSlug}/${segment}`);
}
return (
<nav
aria-label="Primary navigation"
className={cn(
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
'pb-safe-bottom',
// 5 equal-flex slots.
'flex items-end',
)}
>
{TABS_LEFT.map((tab) => (
<NavTab
key={tab.segment}
tab={tab}
portSlug={portSlug}
active={isActive(tab.segment)}
/>
))}
{/* Search button — styled identically to the other navbar tabs. */}
<button
type="button"
onClick={onSearchClick}
className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
>
<Search className="relative size-5" aria-hidden />
<span className="relative font-medium">Search</span>
</button>
{TABS_RIGHT.map((tab) => (
<NavTab
key={tab.segment}
tab={tab}
portSlug={portSlug}
active={isActive(tab.segment)}
/>
))}
<button
type="button"
onClick={onMoreClick}
className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
>
<Menu className="relative size-5" aria-hidden />
<span className="relative font-medium">More</span>
</button>
</nav>
);
}
function NavTab({
tab,
portSlug,
active,
}: {
tab: TabSpec;
portSlug: string;
active: boolean;
}) {
const Icon = tab.icon;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/${tab.segment}` as any}
aria-current={active ? 'page' : undefined}
className={cn(
'relative flex flex-1 flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{/* iOS-native active indicator: a 2px accent bar at the top of
the active tab. Cleaner than a colored pill — relies on the
icon + label color change (text-primary above) to do the
primary signaling, with this bar adding just enough visual
anchor to read as "selected". */}
<span
aria-hidden
className={cn(
'absolute inset-x-0 top-0 mx-auto h-[2px] w-8 rounded-full transition-opacity',
active ? 'bg-primary opacity-100' : 'opacity-0',
)}
/>
<Icon className="relative size-5" aria-hidden />
<span className="relative font-medium">{tab.label}</span>
</Link>
);
}