feat(uat-batch): Groups J + K — activity feed + onboarding resolver-chain
J38, J39, K40 (core) from the 2026-05-21 plan.
Shipped:
J38 EntityActivityFeed sentence rendering surfaces the new value
inline. Was "<actor> updated the X"; now "<actor> set X to
<value>" when the audit row carries `newValue`. Field-level
diff line underneath keeps showing the old → new strikethrough
for context. Truncates inline value at 60 chars to keep long
notes / descriptions from blowing out the row.
J39 Client → Companies tab CTA. Empty state gains a "Link to a
company" action; populated state grows a top-right "Link to
company" button. New <LinkCompanyDialog> wraps the existing
<CompanyPicker> + a membership-role select + an "is primary"
checkbox, then POSTs to /api/v1/companies/[id]/members.
Empty-state copy dropped "Add a membership from a company's
detail page" — the rep can act inline now.
K40 OnboardingChecklist resolver-chain. The auto-check no longer
reads raw `/admin/settings` rows (which miss env fallbacks).
Resolved endpoint widened to accept `?keys=k1,k2,...` so the
checklist can batch-resolve any heterogenous set of registry
keys through port → global → env → default in one round-trip.
Checklist captures the dominant source per step ("env fallback",
"global default", "built-in default") and surfaces it inline
under the green tick so super-admins see when a step is
relying on env rather than a per-port override. Compound-key
gates report the weakest sub-key's source so a partially-env
config still flags clearly.
Topbar banner / dashboard tile / weekly nudge / celebration
sub-items remain queued — the core resolver-chain gap was
the actual cause of the "step never ticks" UAT complaint.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Building2, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Table,
|
||||
@@ -13,7 +17,27 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CompanyPicker } from '@/components/companies/company-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface ClientCompaniesTabProps {
|
||||
clientId: string;
|
||||
@@ -37,22 +61,45 @@ function formatSince(startDate: string | Date): string {
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) {
|
||||
export function ClientCompaniesTab({ clientId, companies }: ClientCompaniesTabProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
|
||||
if (companies.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No company memberships"
|
||||
description="This client is not affiliated with any companies yet. Add a membership from a company's detail page."
|
||||
/>
|
||||
<>
|
||||
<EmptyState
|
||||
title="No company memberships"
|
||||
description="This client is not affiliated with any companies yet."
|
||||
icon={Building2}
|
||||
action={{ label: 'Link to a company', onClick: () => setLinkOpen(true) }}
|
||||
/>
|
||||
<LinkCompanyDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
clientId={clientId}
|
||||
portSlug={portSlug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Company affiliations</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Company affiliations</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setLinkOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
Link to company
|
||||
</Button>
|
||||
</div>
|
||||
<LinkCompanyDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
clientId={clientId}
|
||||
portSlug={portSlug}
|
||||
/>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -101,3 +148,104 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkCompanyDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
clientId: string;
|
||||
portSlug: string;
|
||||
}
|
||||
|
||||
const MEMBERSHIP_ROLES = [
|
||||
{ value: 'director', label: 'Director' },
|
||||
{ value: 'shareholder', label: 'Shareholder' },
|
||||
{ value: 'employee', label: 'Employee' },
|
||||
{ value: 'agent', label: 'Agent' },
|
||||
{ value: 'beneficial_owner', label: 'Beneficial owner' },
|
||||
{ value: 'authorised_signatory', label: 'Authorised signatory' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
function LinkCompanyDialog({ open, onOpenChange, clientId, portSlug }: LinkCompanyDialogProps) {
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string>('director');
|
||||
const [isPrimary, setIsPrimary] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!companyId) throw new Error('Pick a company');
|
||||
await apiFetch(`/api/v1/companies/${companyId}/members`, {
|
||||
method: 'POST',
|
||||
body: { clientId, role, isPrimary },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Membership added');
|
||||
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
setCompanyId(null);
|
||||
setRole('director');
|
||||
setIsPrimary(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link client to a company</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick an existing company or{' '}
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/companies?create=1` as any}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
create a new one
|
||||
</Link>
|
||||
, then choose this client's role in it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Company</Label>
|
||||
<CompanyPicker value={companyId} onChange={setCompanyId} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEMBERSHIP_ROLES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrimary}
|
||||
onChange={(e) => setIsPrimary(e.target.checked)}
|
||||
/>
|
||||
Set as primary affiliation
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={create.isPending || !companyId}>
|
||||
Link
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user