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>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Loader2, Pencil } from 'lucide-react';
|
|
|
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
import { parsePhone } from '@/lib/i18n/phone';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface InlinePhoneFieldProps {
|
|
/** E.164 form ('+442079460958'), null when unset. */
|
|
e164: string | null | undefined;
|
|
/** ISO-3166-1 alpha-2 the number was parsed against. */
|
|
country: string | null | undefined;
|
|
/** Falls back to this country if `country` isn't set. */
|
|
defaultCountry?: CountryCode;
|
|
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
|
|
/**
|
|
* Notifies the parent when the field enters/exits edit mode. Lets the row
|
|
* dim or hide noise (tag chips, action buttons) while the user is focused
|
|
* on the editor.
|
|
*/
|
|
onEditingChange?: (editing: boolean) => void;
|
|
emptyText?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
'data-testid'?: string;
|
|
}
|
|
|
|
export function InlinePhoneField({
|
|
e164,
|
|
country,
|
|
defaultCountry,
|
|
onSave,
|
|
onEditingChange,
|
|
emptyText = '-',
|
|
disabled,
|
|
className,
|
|
'data-testid': testId,
|
|
}: InlinePhoneFieldProps) {
|
|
const [editing, setEditingRaw] = useState(false);
|
|
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
|
|
if (!e164 && !country) return null;
|
|
return {
|
|
e164: e164 ?? null,
|
|
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
|
|
};
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
function setEditing(next: boolean) {
|
|
setEditingRaw(next);
|
|
onEditingChange?.(next);
|
|
}
|
|
|
|
async function commit() {
|
|
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
|
|
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await onSave(next);
|
|
setEditing(false);
|
|
} catch (err) {
|
|
toastError(err);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (editing) {
|
|
return (
|
|
// Two clean lines: country picker + number on top, action pair below.
|
|
<div className={cn('flex w-full flex-col gap-2.5', className)}>
|
|
<PhoneInput
|
|
value={draft}
|
|
onChange={(v) => setDraft(v)}
|
|
defaultCountry={defaultCountry}
|
|
data-testid={testId}
|
|
/>
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraft(
|
|
e164 || country
|
|
? {
|
|
e164: e164 ?? null,
|
|
country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
|
|
}
|
|
: null,
|
|
);
|
|
setEditing(false);
|
|
}}
|
|
disabled={saving}
|
|
className={cn(
|
|
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
|
|
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
|
'disabled:opacity-50',
|
|
)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void commit()}
|
|
disabled={saving}
|
|
className={cn(
|
|
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
|
|
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
|
|
'transition-colors hover:bg-primary/90 disabled:opacity-50',
|
|
)}
|
|
>
|
|
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Display: prefer the parsed national format (more readable than raw E.164).
|
|
let display: string | null = null;
|
|
if (e164) {
|
|
const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry);
|
|
display = parsed.national ?? e164;
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setEditing(true)}
|
|
data-testid={testId}
|
|
className={cn(
|
|
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
|
|
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
|
|
className,
|
|
)}
|
|
>
|
|
<span className={cn('flex-1', !display && 'text-muted-foreground')}>
|
|
{display ?? emptyText}
|
|
</span>
|
|
{!disabled && (
|
|
<Pencil className="h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|