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,6 +13,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { ClientPicker } from '@/components/shared/client-picker';
import { InterestPicker } from '@/components/shared/interest-picker';
import { BerthPicker } from '@/components/shared/berth-picker';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
@@ -172,7 +175,9 @@ export function ReminderForm({
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 2fr/1fr split — the datetime-local control needs more room
for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
<div className="grid grid-cols-[2fr_1fr] gap-4">
<div className="space-y-2">
<Label htmlFor="reminder-due">Due Date & Time</Label>
<Input
@@ -202,13 +207,18 @@ export function ReminderForm({
{canAssignOthers && (
<div className="space-y-2">
<Label htmlFor="reminder-assign">Assign To</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<Label htmlFor="reminder-assign">Assign to user</Label>
<Select
value={assignedTo === '' ? '__self__' : assignedTo}
onValueChange={(v) => setAssignedTo(v === '__self__' ? '' : v)}
>
<SelectTrigger id="reminder-assign">
<SelectValue placeholder="Myself" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Myself</SelectItem>
{/* Radix Select forbids empty-string values, so use a
sentinel here and map back to '' in the handler. */}
<SelectItem value="__self__">Myself</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName}
@@ -220,27 +230,36 @@ export function ReminderForm({
)}
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">
Link to Entity (optional - paste UUIDs, or leave blank)
<Label className="text-xs text-muted-foreground">
Link to entity (optional)
</Label>
<p className="text-[11px] text-muted-foreground">
Pick a client first to scope the interest and berth dropdowns to that
client&apos;s deals.
</p>
<div className="grid grid-cols-1 gap-2">
<Input
placeholder="Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className="text-xs"
<ClientPicker
value={clientId || null}
onChange={(id) => {
setClientId(id ?? '');
// Clearing the client also clears scoped selections so a
// stale interest/berth from a different client doesn't
// silently submit alongside the new client.
if (!id) {
setInterestId('');
setBerthId('');
}
}}
/>
<Input
placeholder="Interest ID"
value={interestId}
onChange={(e) => setInterestId(e.target.value)}
className="text-xs"
<InterestPicker
value={interestId || null}
onChange={(id) => setInterestId(id ?? '')}
clientId={clientId || null}
/>
<Input
placeholder="Berth ID"
value={berthId}
onChange={(e) => setBerthId(e.target.value)}
className="text-xs"
<BerthPicker
value={berthId || null}
onChange={(id) => setBerthId(id ?? '')}
clientId={clientId || null}
/>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useParams } from 'next/navigation';
@@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Select,
SelectContent,
@@ -19,6 +25,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePermissions } from '@/hooks/use-permissions';
import { ReminderCard } from './reminder-card';
import { ReminderForm } from './reminder-form';
@@ -59,10 +66,20 @@ const STATUS_CONFIG = {
dismissed: { label: 'Dismissed', icon: XCircle },
} as const;
export function ReminderList() {
interface ReminderListProps {
/**
* Embedded mode (used by the Inbox page) drops the PageHeader and
* surfaces the "New Reminder" button inline so the section can render
* alongside the Alerts section without duplicating page chrome.
*/
embedded?: boolean;
}
export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [snoozingId, setSnoozingId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
@@ -203,41 +220,97 @@ export function ReminderList() {
return null;
}
return (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="text-green-600 hover:text-green-700"
onClick={() => handleComplete(row.original.id)}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
<Clock className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</div>
<TooltipProvider delayDuration={150}>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Mark complete"
className="text-green-600 hover:text-green-700"
onClick={() => handleComplete(row.original.id)}
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark complete</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Snooze"
onClick={() => setSnoozingId(row.original.id)}
>
<Clock className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Snooze</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Edit reminder"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setEditingReminder(row.original);
setFormOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Dismiss"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
size: 120,
size: 160,
},
];
return (
<div>
<PageHeader
title="Reminders"
description={`${total} reminder${total !== 1 ? 's' : ''}`}
actions={
{!embedded ? (
<PageHeader
title="Reminders"
description={`${total} reminder${total !== 1 ? 's' : ''}`}
actions={
<Button
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
}
/>
) : (
<div className="mb-3 flex justify-end">
<Button
size="sm"
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
@@ -246,8 +319,8 @@ export function ReminderList() {
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
}
/>
</div>
)}
{/* Wrap on phone widths so the priority filter doesn't get pushed
off-screen by the My/All tabs + status filter taking the full row. */}