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

@@ -13,7 +13,7 @@ import {
Building2,
Receipt,
FileText,
Bell,
Inbox,
Camera,
Globe,
Settings,
@@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
title: 'Communication',
marinaRequired: true,
items: [
// Email tab removed: we deferred building a full inbox/threading
// Email tab removed: deferred building a full inbox/threading
// feature (would require Google OAuth + scope review + IMAP
// syncing infra). Reminders stays since it's already wired up.
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
// syncing infra). This entry routes to the merged
// Alerts + Reminders surface (2026-05-11) — explicit name so
// reps don't mistake it for an email inbox.
{ href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
],
},
{
@@ -188,8 +190,8 @@ function NavItemLink({
href={item.href as any}
className={cn(
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
active && 'text-white pl-[14px]',
'text-slate-700 hover:bg-accent hover:text-foreground',
active && 'bg-accent text-foreground pl-[14px]',
collapsed && 'justify-center px-2',
)}
>
@@ -202,7 +204,7 @@ function NavItemLink({
<item.icon
className={cn(
'shrink-0',
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]',
active ? 'text-[#3a7bc8]' : 'text-slate-500',
collapsed ? 'w-5 h-5' : 'w-4 h-4',
)}
/>
@@ -252,7 +254,7 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug);
const umami = useUmamiActive('today');
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
// Small label under the user identity when the user has access to more
// than one port — disambiguates which port is currently active without
@@ -283,12 +285,13 @@ function SidebarContent({
// compete with the logo for attention.
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-col h-full bg-[#1e2844]">
{/* Brand header - logo centered (large when expanded, smaller when
collapsed). Collapse toggle floats top-right as a tiny chevron. */}
<div className="flex flex-col h-full bg-white">
{/* Brand header - logo centered. Soft hairline below echoes the
inter-section separators in the nav so the eye reads the logo
as a distinct top-row, not a floating element. */}
<div
className={cn(
'relative flex items-center justify-center border-b border-[#474e66]',
'relative flex items-center justify-center border-b border-slate-200',
collapsed ? 'h-16 px-2' : 'h-24 px-4',
)}
>
@@ -297,7 +300,7 @@ function SidebarContent({
alt="Port Nimara"
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
className="rounded-full shadow-md ring-2 ring-white/20"
className="rounded-full shadow-sm"
unoptimized
priority
/>
@@ -307,7 +310,7 @@ function SidebarContent({
onClick={onToggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors',
collapsed ? 'top-1' : 'top-2',
)}
>
@@ -333,13 +336,13 @@ function SidebarContent({
<div key={section.title}>
{!collapsed && (
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[#83aab1] text-[10px] font-semibold uppercase tracking-[0.12em]">
<span className="text-slate-500 text-[10px] font-semibold uppercase tracking-[0.12em]">
{section.title}
</span>
{section.adminRequired && (
<button
onClick={() => setAdminExpanded((v) => !v)}
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
className="text-slate-400 hover:text-slate-700 transition-colors"
>
{adminExpanded ? (
<ChevronUp className="w-3 h-3" />
@@ -363,7 +366,7 @@ function SidebarContent({
))}
</ul>
)}
<Separator className="mt-3 bg-[#474e66]/50" />
<Separator className="mt-3 bg-slate-200" />
</div>
);
})}
@@ -374,7 +377,7 @@ function SidebarContent({
user can click their name/avatar to access Profile / Settings /
port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */}
<div className={cn('border-t border-[#474e66] p-2', collapsed && 'flex justify-center')}>
<div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>
{collapsed ? (
<UserMenu
align="start"
@@ -384,7 +387,7 @@ function SidebarContent({
<button
type="button"
aria-label="Open user menu"
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
>
<Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} />
@@ -404,26 +407,26 @@ function SidebarContent({
<button
type="button"
aria-label="Open user menu"
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-[#171f35] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
>
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30">
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-slate-200">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
<p className="text-foreground text-sm font-medium truncate">
{user?.name ?? 'User'}
</p>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
className="text-[10px] px-1.5 py-0 text-slate-500 border-slate-300 mt-0.5"
>
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
</Badge>
{currentPortName && (
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p>
<p className="mt-1 text-[10px] text-slate-400 truncate">{currentPortName}</p>
)}
</div>
</button>
@@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
return (
<aside
className={cn(
'relative hidden md:flex flex-col h-screen border-r border-[#474e66] transition-all duration-200 ease-in-out shrink-0',
'relative hidden md:flex flex-col h-screen border-r border-slate-200 transition-all duration-200 ease-in-out shrink-0 bg-white',
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
)}
style={{ backgroundColor: '#1e2844' }}
>
<SidebarContent
collapsed={sidebarCollapsed}