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:
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Bell,
|
||||
@@ -100,6 +101,7 @@ export function CommandSearch() {
|
||||
const [focusIndex, setFocusIndex] = useState<number>(-1);
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -113,8 +115,32 @@ export function CommandSearch() {
|
||||
limit: activeBucket === 'all' ? 5 : 15,
|
||||
});
|
||||
|
||||
// Persist the totals from the last "all" query so the filter chips stay
|
||||
// populated when the user narrows to a single bucket. Without this, the
|
||||
// narrowed query only returns counts for the active bucket and every
|
||||
// other chip would vanish — making it impossible to swap between
|
||||
// filters without clearing back to "All" first.
|
||||
const lastAllTotalsRef = useRef<SearchResults['totals'] | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeBucket === 'all' && results?.totals) {
|
||||
lastAllTotalsRef.current = results.totals;
|
||||
}
|
||||
}, [activeBucket, results]);
|
||||
const chipTotals: SearchResults['totals'] | undefined =
|
||||
activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
|
||||
|
||||
const showDropdown = focused;
|
||||
|
||||
// CommandSearch lives in the header and persists across navigations,
|
||||
// so its React Query cache never sees a remount. Invalidate the
|
||||
// recently-viewed + recent-terms queries whenever the dropdown opens
|
||||
// so the user sees fresh data after navigating around the app.
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
|
||||
}, [showDropdown, queryClient]);
|
||||
|
||||
// Cmd/Ctrl+K focuses the input from anywhere on the page.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: globalThis.KeyboardEvent) {
|
||||
@@ -287,7 +313,7 @@ export function CommandSearch() {
|
||||
>
|
||||
{/* Filter chip row — always visible while the dropdown is open. */}
|
||||
<FilterChipRow
|
||||
results={results}
|
||||
totals={chipTotals}
|
||||
active={activeBucket}
|
||||
onChange={setActiveBucket}
|
||||
disabled={query.length < 2}
|
||||
@@ -337,18 +363,19 @@ export function CommandSearch() {
|
||||
// ─── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChipRow({
|
||||
results,
|
||||
totals,
|
||||
active,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
results: SearchResults | undefined;
|
||||
/** Counts from the last "all" query, persisted so chips stay visible
|
||||
* when the user narrows to a single bucket. Falls back to the current
|
||||
* results.totals when no "all" snapshot exists yet. */
|
||||
totals: SearchResults['totals'] | undefined;
|
||||
active: BucketType | 'all';
|
||||
onChange: (b: BucketType | 'all') => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// Show a chip for every bucket so the user can browse the search
|
||||
// surface even with no query; counts only render when results exist.
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -364,10 +391,10 @@ function FilterChipRow({
|
||||
All
|
||||
</ChipButton>
|
||||
{BUCKETS.map((b) => {
|
||||
const count = results?.totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets the current user can't see (count === 0
|
||||
// when the bucket query was permission-skipped) — but only after
|
||||
// a query has run, otherwise we'd hide every chip on first paint.
|
||||
const count = totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets with zero matches in the last "all"
|
||||
// snapshot — keeps the row tight and avoids dead-end clicks.
|
||||
// Always show the active chip + every chip before a query has run.
|
||||
if (!disabled && count === 0 && active !== b.type) return null;
|
||||
return (
|
||||
<ChipButton
|
||||
@@ -701,6 +728,11 @@ function ResultRow({
|
||||
<HighlightMatch text={row.sub} query={query} />
|
||||
</div>
|
||||
)}
|
||||
{row.relatedVia && (
|
||||
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
|
||||
via {row.relatedVia.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -759,9 +791,9 @@ function BucketSection({
|
||||
|
||||
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
|
||||
|
||||
type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
|
||||
type FlatRow =
|
||||
export type FlatRow =
|
||||
| {
|
||||
kind: 'recent-view';
|
||||
key: string;
|
||||
@@ -783,6 +815,9 @@ type FlatRow =
|
||||
sub: string | null;
|
||||
href: string;
|
||||
badges?: ResultBadge[];
|
||||
/** Provenance hint when the row was surfaced via graph expansion.
|
||||
* Rendered as a subtle "via Berth A10" line below the sub. */
|
||||
relatedVia?: { type: string; label: string } | null;
|
||||
}
|
||||
| {
|
||||
kind: 'other-port';
|
||||
@@ -791,7 +826,7 @@ type FlatRow =
|
||||
href: string;
|
||||
};
|
||||
|
||||
interface BuildFlatRowsArgs {
|
||||
export interface BuildFlatRowsArgs {
|
||||
query: string;
|
||||
results: SearchResults | undefined;
|
||||
recentlyViewed: RecentlyViewedItem[];
|
||||
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
|
||||
portSlug: string | null;
|
||||
}
|
||||
|
||||
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: c.matchedContact ?? null,
|
||||
href: `/${portSlug}/clients/${c.id}`,
|
||||
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
|
||||
relatedVia: c.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: y.name,
|
||||
sub,
|
||||
href: `/${portSlug}/yachts/${y.id}`,
|
||||
relatedVia: y.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: co.name,
|
||||
sub,
|
||||
href: `/${portSlug}/companies/${co.id}`,
|
||||
relatedVia: co.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: i.berthMooringNumber,
|
||||
href: `/${portSlug}/interests/${i.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: i.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub,
|
||||
href: `/${portSlug}/berths/${b.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: b.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user