Five small fixes from the third audit pass on previously-unchecked surfaces:
Yacht detail header (mobile):
- Stack the action cluster (Edit / Transfer / Archive) below the title
block on phone widths. Previously the three buttons crowded the right
side enough to truncate the status pill to "A..." and force the owner
name to wrap to two lines. Same fix that landed for berth / client /
company headers.
Company detail header (mobile):
- Same mobile stacking fix; legal-name + Tax-ID metadata no longer
wraps awkwardly.
Company detail Incorporation Date (all viewports):
- Strip the time portion of the ISO timestamp before passing to the
inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z"
Postgres-serialized form. Now reads "2019-03-14" and round-trips
through the YYYY-MM-DD inline editor cleanly.
Reminders list filter row:
- Allow flex-wrap on the My/All tabs + status filter + priority filter
cluster. At 390px, the priority filter dropdown was being pushed off
the right edge of the screen.
Client detail tab counts:
- Add interestCount + noteCount to getClientById response, surface as
badges on the Interests + Notes tabs. Brings them into parity with
Yachts/Companies/Reservations/Addresses which already showed counts;
Files + Activity are still stubs and don't get a count yet.
Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
Out of scope (deferred):
- Residential clients / interests pages still render plain HTML tables
on phone widths (header columns clip at the right edge). Needs the
DataView card-on-mobile treatment that the main /clients and
/interests pages already have. Substantial separate work.
- Phone contacts in the legacy seed have value set but valueE164 NULL,
so InlinePhoneField shows "—" even though metadata is technically
populated. Fix is a one-time backfill via libphonenumber-js, not a
UI change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
7.9 KiB
TypeScript
243 lines
7.9 KiB
TypeScript
'use client';
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
|
import { EmptyState } from '@/components/shared/empty-state';
|
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
|
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
|
import { NotesList } from '@/components/shared/notes-list';
|
|
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
|
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
|
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
|
|
type CompanyPatchField =
|
|
| 'name'
|
|
| 'legalName'
|
|
| 'taxId'
|
|
| 'registrationNumber'
|
|
| 'incorporationCountry'
|
|
| 'incorporationCountryIso'
|
|
| 'incorporationSubdivisionIso'
|
|
| 'incorporationDate'
|
|
| 'status'
|
|
| 'billingEmail'
|
|
| 'notes';
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'active', label: 'Active' },
|
|
{ value: 'dissolved', label: 'Dissolved' },
|
|
];
|
|
|
|
interface CompanyTabsCompany {
|
|
id: string;
|
|
name: string;
|
|
legalName: string | null;
|
|
taxId: string | null;
|
|
registrationNumber: string | null;
|
|
incorporationCountryIso: string | null;
|
|
incorporationSubdivisionIso: string | null;
|
|
incorporationDate: string | null;
|
|
status: string;
|
|
billingEmail: string | null;
|
|
notes: string | null;
|
|
tags?: Array<{ id: string; name: string; color: string }>;
|
|
addresses?: Address[];
|
|
}
|
|
|
|
interface CompanyTabsOptions {
|
|
companyId: string;
|
|
portSlug: string;
|
|
currentUserId?: string;
|
|
company: CompanyTabsCompany;
|
|
}
|
|
|
|
function useCompanyPatch(companyId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (patch: Partial<Record<CompanyPatchField, string | null>>) =>
|
|
apiFetch(`/api/v1/companies/${companyId}`, {
|
|
method: 'PATCH',
|
|
body: patch,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['companies', companyId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
|
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
|
<dd className="flex-1 min-w-0">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OverviewTab({ companyId, company }: { companyId: string; company: CompanyTabsCompany }) {
|
|
const mutation = useCompanyPatch(companyId);
|
|
const save = (field: CompanyPatchField) => async (next: string | null) => {
|
|
await mutation.mutateAsync({ [field]: next });
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Identity */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
|
<dl>
|
|
<EditableRow label="Name">
|
|
<InlineEditableField value={company.name} onSave={save('name')} />
|
|
</EditableRow>
|
|
<EditableRow label="Legal Name">
|
|
<InlineEditableField value={company.legalName} onSave={save('legalName')} />
|
|
</EditableRow>
|
|
<EditableRow label="Status">
|
|
<InlineEditableField
|
|
variant="select"
|
|
options={STATUS_OPTIONS}
|
|
value={company.status}
|
|
onSave={save('status')}
|
|
/>
|
|
</EditableRow>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Registration */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
|
<dl>
|
|
<EditableRow label="Tax ID">
|
|
<InlineEditableField value={company.taxId} onSave={save('taxId')} />
|
|
</EditableRow>
|
|
<EditableRow label="Registration Number">
|
|
<InlineEditableField
|
|
value={company.registrationNumber}
|
|
onSave={save('registrationNumber')}
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Incorporation Country">
|
|
<InlineCountryField
|
|
value={company.incorporationCountryIso}
|
|
onSave={async (iso) => {
|
|
// Wipe subdivision when country flips — codes are country-scoped.
|
|
await mutation.mutateAsync({
|
|
incorporationCountryIso: iso,
|
|
incorporationSubdivisionIso: null,
|
|
});
|
|
}}
|
|
data-testid="company-incorp-country-inline"
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Incorporation Region">
|
|
<SubdivisionCombobox
|
|
value={company.incorporationSubdivisionIso}
|
|
onChange={(code) => {
|
|
void mutation.mutateAsync({ incorporationSubdivisionIso: code });
|
|
}}
|
|
country={(company.incorporationCountryIso as CountryCode | null) ?? null}
|
|
data-testid="company-incorp-subdivision-inline"
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Incorporation Date">
|
|
<InlineEditableField
|
|
// The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
|
|
// because Postgres `date` columns are serialized through JSON. Strip
|
|
// the time portion so the read-only state shows just YYYY-MM-DD,
|
|
// which is also the format the user types when editing.
|
|
value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null}
|
|
placeholder="YYYY-MM-DD"
|
|
onSave={save('incorporationDate')}
|
|
/>
|
|
</EditableRow>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Contact */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
|
<dl>
|
|
<EditableRow label="Billing Email">
|
|
<InlineEditableField value={company.billingEmail} onSave={save('billingEmail')} />
|
|
</EditableRow>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="space-y-1 md:col-span-2">
|
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
|
<InlineEditableField
|
|
variant="textarea"
|
|
value={company.notes}
|
|
onSave={save('notes')}
|
|
emptyText="No notes — click to add"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="space-y-1 md:col-span-2">
|
|
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
|
<InlineTagEditor
|
|
endpoint={`/api/v1/companies/${companyId}/tags`}
|
|
currentTags={company.tags ?? []}
|
|
invalidateKey={['companies', companyId]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function getCompanyTabs({
|
|
companyId,
|
|
portSlug,
|
|
currentUserId,
|
|
company,
|
|
}: CompanyTabsOptions): DetailTab[] {
|
|
return [
|
|
{
|
|
id: 'overview',
|
|
label: 'Overview',
|
|
content: <OverviewTab companyId={companyId} company={company} />,
|
|
},
|
|
{
|
|
id: 'members',
|
|
label: 'Members',
|
|
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
|
|
},
|
|
{
|
|
id: 'owned-yachts',
|
|
label: 'Owned Yachts',
|
|
content: <CompanyOwnedYachtsTab companyId={companyId} portSlug={portSlug} />,
|
|
},
|
|
{
|
|
id: 'addresses',
|
|
label: 'Addresses',
|
|
badge: company.addresses?.length ?? 0,
|
|
content: (
|
|
<AddressesEditor
|
|
endpoint={`/api/v1/companies/${companyId}/addresses`}
|
|
invalidateKey={['companies', companyId]}
|
|
addresses={company.addresses ?? []}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: 'documents',
|
|
label: 'Documents',
|
|
content: <EmptyState title="Documents" description="Coming soon" />,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Notes',
|
|
content: (
|
|
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
|
|
),
|
|
},
|
|
];
|
|
}
|