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

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -12,35 +12,26 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/
};
// Bottom nav ordering, left → right:
// Dashboard - daily overview
// Berths - marina inventory grid (touches sales + ops both)
// Clients - the address book / dedup surface (centered: it's the
// primary mental anchor for "find this person", with
// interests living as a tab on the client detail rather
// than a peer in the bottom nav)
// Documents - signature tracking (chase signers, EOI queue)
// More - overflow drawer (Interests, Yachts, Companies, …)
//
// Interests is intentionally NOT in the bottom row - having both Clients
// and Interests as peer tabs created a Clients-vs-Interests confusion
// for sales reps, and the per-client interests tab + the new bottom-sheet
// drawer cover the day-to-day deal review without needing a dedicated tab.
// Yachts stays out for the same reason as before: it's an asset record
// most often reached from inside an interest or client, not browsed.
const TABS: TabSpec[] = [
// 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: 'Berths', icon: Anchor, segment: 'berths' },
{ label: 'Clients', icon: Users, segment: 'clients' },
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
];
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
const pathname = usePathname();
const TABS_RIGHT: TabSpec[] = [
{ label: 'Berths', icon: Anchor, segment: 'berths' },
];
// Derive the active port slug from the URL so tab links always target the
// current port, even after a port-switch. The dashboard route shape is
// /[portSlug]/<rest>, so the slug is the first non-empty path segment.
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 {
@@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
className={cn(
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
'pb-safe-bottom',
'grid grid-cols-5',
// 5 equal-flex slots.
'flex items-end',
)}
>
{TABS.map((tab) => {
const active = isActive(tab.segment);
const Icon = tab.icon;
return (
<Link
key={tab.segment}
// 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-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{/* Subtle pill background behind the icon when active. Keeps the
tab grid alignment intact while giving the eye an anchor. */}
<span
aria-hidden
className={cn(
'absolute top-1.5 h-7 w-12 rounded-full transition-all',
active ? 'bg-primary/10' : 'bg-transparent',
)}
/>
<Icon className="relative size-5" aria-hidden />
<span className="relative font-medium">{tab.label}</span>
</Link>
);
})}
{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 flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground transition-colors"
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>
@@ -95,3 +87,41 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
</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>
);
}

View File

@@ -7,6 +7,7 @@ import { MobileLayoutProvider } from './mobile-layout-provider';
import { MobileTopbar } from './mobile-topbar';
import { MobileBottomTabs } from './mobile-bottom-tabs';
import { MoreSheet } from './more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
/**
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
@@ -17,6 +18,7 @@ import { MoreSheet } from './more-sheet';
*/
export function MobileLayout({ children }: { children: ReactNode }) {
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
return (
<div data-shell="mobile" className="min-h-screen bg-background">
@@ -33,8 +35,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
>
{children}
</main>
<MobileBottomTabs onMoreClick={() => setMoreOpen(true)} />
<MobileBottomTabs
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</MobileLayoutProvider>
</div>
);

View File

@@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation';
import {
Anchor,
BarChart3,
Bell,
BellRing,
Bookmark,
Building2,
FileSignature,
Globe,
Home,
Inbox,
Receipt,
Settings,
Shield,
ShieldAlert,
Ship,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import {
Drawer,
DrawerContent,
@@ -26,6 +27,7 @@ import {
DrawerClose,
} from '@/components/shared/drawer';
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
import { apiFetch } from '@/lib/api/client';
type MoreItem = {
label: string;
@@ -33,32 +35,50 @@ type MoreItem = {
segment: string;
};
// Order: most-likely overflow targets first. Interests is here (rather
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
// reps reach the active deals via the Interests tab on a client detail
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
// best reached contextually from inside an interest or client.
type MoreGroup = {
label: string;
items: MoreItem[];
};
// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable
// spatial layout — reps' muscle memory survives — while making the
// "kind of thing" each tile is explicit. Three sections:
// - Records: entity lists (people, vessels, properties)
// - Operations: daily-use action surfaces
// - Configuration: port-level setup, hidden from most reps
//
// Inbox is intentionally absent — the email/threading inbox feature was
// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring
// + Google OAuth review are done. Website analytics is filtered below
// when Umami isn't configured for this port.
const MORE_ITEMS: MoreItem[] = [
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
// Notifications themselves live on the topbar bell — this entry deep-links
// to the notification panel inside user-settings (collapsed in 2026-05-09).
{ label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
{ label: 'Reminders', icon: Bell, segment: 'reminders' },
{ label: 'Settings', icon: Settings, segment: 'settings' },
{ label: 'Admin', icon: Shield, segment: 'admin' },
// Interests stays here (not bottom nav) to dodge the Clients-vs-
// Interests UX confusion. Inbox replaces the previously-separate
// Alerts + Reminders entries (merged 2026-05-11). Website analytics
// and Reservations are filtered out below when not applicable.
const MORE_GROUPS: MoreGroup[] = [
{
label: 'Records',
items: [
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
],
},
{
label: 'Operations',
items: [
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
],
},
{
label: 'Configuration',
items: [
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
{ label: 'Settings', icon: Settings, segment: 'settings' },
{ label: 'Admin', icon: Shield, segment: 'admin' },
],
},
];
export function MoreSheet({
@@ -74,10 +94,30 @@ export function MoreSheet({
// Hide "Website analytics" if Umami isn't wired up for this port — the
// dedicated tile on the dashboard already does the same.
const umami = useUmamiActive('today');
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
const items = MORE_ITEMS.filter(
(item) => item.segment !== 'website-analytics' || umamiConfigured,
);
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
// Hide "Reservations" until at least one exists for this port — until the
// marina has confirmed bookings, the page is empty and surfaces nothing
// useful. Cheap count via pageSize=1; cached 5 min so opening the sheet
// repeatedly doesn't refetch.
const reservations = useQuery<{ pagination?: { total: number } }>({
queryKey: ['berth-reservations', 'sheet-count'],
queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'),
staleTime: 5 * 60_000,
enabled: open,
});
const hasReservations =
!reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0;
// Per-group filter: keep only the items relevant to this port's state.
const groups = MORE_GROUPS.map((g) => ({
...g,
items: g.items.filter((item) => {
if (item.segment === 'website-analytics') return umamiConfigured;
if (item.segment === 'berth-reservations') return hasReservations;
return true;
}),
})).filter((g) => g.items.length > 0);
return (
<Drawer open={open} onOpenChange={onOpenChange}>
@@ -85,28 +125,36 @@ export function MoreSheet({
<DrawerHeader>
<DrawerTitle>More</DrawerTitle>
</DrawerHeader>
<ul className="grid grid-cols-3 gap-2 px-3 pb-4">
{items.map((item) => {
const Icon = item.icon;
return (
<li key={item.segment}>
<DrawerClose asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/${item.segment}` as any}
// min-h-[88px] guarantees a 44pt vertical touch target
// (Apple HIG); icon + label centered. The grid gap is
// 8px so each cell still has clearance from neighbours.
className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-xs text-foreground hover:bg-accent active:bg-accent/80"
>
<Icon className="size-7 text-muted-foreground" aria-hidden />
<span className="font-medium">{item.label}</span>
</Link>
</DrawerClose>
</li>
);
})}
</ul>
<div className="space-y-4 px-3 pb-4">
{groups.map((group) => (
<section key={group.label}>
<h3 className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<ul className="grid grid-cols-3 gap-2">
{group.items.map((item) => {
const Icon = item.icon;
return (
<li key={item.segment}>
<DrawerClose asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/${item.segment}` as any}
// min-h-[88px] guarantees a 44pt vertical touch
// target (Apple HIG); icon + label centered.
className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-center text-xs text-foreground hover:bg-accent active:bg-accent/80"
>
<Icon className="size-7 text-muted-foreground" aria-hidden />
<span className="font-medium leading-tight">{item.label}</span>
</Link>
</DrawerClose>
</li>
);
})}
</ul>
</section>
))}
</div>
</DrawerContent>
</Drawer>
);

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}

View File

@@ -95,23 +95,40 @@ export function Topbar({ ports, user }: TopbarProps) {
<span className="hidden sm:inline">New</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Each item routes to the list page with ?create=1 so the
relevant create sheet pops automatically (see
useCreateFromUrl). The legacy `/clients/new`-style routes
this menu used to push to landed on the dynamic detail
page with id="new" and silently 404'd. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}>
<DropdownMenuItem onClick={() => router.push(`${base}/clients?create=1` as any)}>
New Client
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/interests/new` as any)}>
<DropdownMenuItem onClick={() => router.push(`${base}/yachts?create=1` as any)}>
New Yacht
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/companies?create=1` as any)}>
New Company
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/interests?create=1` as any)}>
New Interest
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/expenses/new` as any)}>
<DropdownMenuItem onClick={() => router.push(`${base}/expenses?create=1` as any)}>
New Expense
</DropdownMenuItem>
{/* /reminders 301s to /inbox#reminders (the merged page) and
the server redirect strips the query string, so point
straight at the new path. The Reminders section's
useCreateFromUrl handler still picks up ?create=1. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}>
<DropdownMenuItem onClick={() => router.push(`${base}/inbox?create=1#reminders` as any)}>
New Reminder
</DropdownMenuItem>
</DropdownMenuContent>