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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user