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:
@@ -2,16 +2,20 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { entriesForSections } from '@/lib/settings/registry';
|
import { entriesForSections, registryFor } from '@/lib/settings/registry';
|
||||||
import { resolveForAdminAPI } from '@/lib/settings/resolver';
|
import { resolveForAdminAPI } from '@/lib/settings/resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
|
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
|
||||||
|
* GET /api/v1/admin/settings/resolved?keys=branding_logo_url,smtp_host_override
|
||||||
*
|
*
|
||||||
* Returns the resolved value + source (port/global/env/default) for every
|
* Returns the resolved value + source (port/global/env/default) for every
|
||||||
* registry entry in the requested sections. Drives the registry-driven
|
* requested registry entry. Drives both the registry-driven admin form
|
||||||
* admin form: the `source` field gates the "Using env fallback" badge.
|
* (sections param) and the onboarding-checklist auto-detection (keys
|
||||||
|
* param) — both need port→global→env→default resolution rather than the
|
||||||
|
* raw `/admin/settings` rows (which only show DB writes).
|
||||||
*
|
*
|
||||||
|
* Either parameter is supported; if both are present the sets union.
|
||||||
* Sensitive fields surface `isSet` only — never the decrypted value.
|
* Sensitive fields surface `isSet` only — never the decrypted value.
|
||||||
*/
|
*/
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
@@ -19,14 +23,33 @@ export const GET = withAuth(
|
|||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const sectionsParam = url.searchParams.get('sections');
|
const sectionsParam = url.searchParams.get('sections');
|
||||||
if (!sectionsParam) {
|
const keysParam = url.searchParams.get('keys');
|
||||||
|
if (!sectionsParam && !keysParam) {
|
||||||
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
|
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
|
||||||
}
|
}
|
||||||
const sections = sectionsParam
|
const sections = sectionsParam
|
||||||
.split(',')
|
? sectionsParam
|
||||||
.map((s) => s.trim())
|
.split(',')
|
||||||
.filter(Boolean);
|
.map((s) => s.trim())
|
||||||
const entries = entriesForSections(sections);
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extraKeys = keysParam
|
||||||
|
? keysParam
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const sectionEntries = entriesForSections(sections);
|
||||||
|
const keyEntries = extraKeys
|
||||||
|
.map((k) => registryFor(k))
|
||||||
|
.filter((e): e is NonNullable<typeof e> => Boolean(e));
|
||||||
|
// Dedupe by `key` so section + key overlap doesn't double-resolve.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const entries = [...sectionEntries, ...keyEntries].filter((e) => {
|
||||||
|
if (seen.has(e.key)) return false;
|
||||||
|
seen.add(e.key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const keys = entries.map((e) => e.key);
|
const keys = entries.map((e) => e.key);
|
||||||
const resolved = await resolveForAdminAPI(keys, ctx.portId);
|
const resolved = await resolveForAdminAPI(keys, ctx.portId);
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ const STEPS: OnboardingStep[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface ResolvedValue {
|
||||||
|
isSet: boolean;
|
||||||
|
source?: 'port' | 'global' | 'env' | 'default' | 'none';
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
interface ResolvedResp {
|
||||||
|
data: { entries: Array<{ key: string }>; values: Record<string, ResolvedValue> };
|
||||||
|
}
|
||||||
interface SettingRow {
|
interface SettingRow {
|
||||||
key: string;
|
key: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -125,6 +133,11 @@ export function OnboardingChecklist() {
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
|
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
|
||||||
|
// Per-step source flags — populated for steps whose auto-check resolved
|
||||||
|
// via the env / default fallback rather than a port / global override.
|
||||||
|
// Surfaces "Resolving from env" copy so super admins see what's
|
||||||
|
// backing each green tick without digging into the settings page.
|
||||||
|
const [autoSources, setAutoSources] = useState<Record<string, ResolvedValue['source']>>({});
|
||||||
const [manualChecks, setManualChecks] = useState<Record<string, boolean>>({});
|
const [manualChecks, setManualChecks] = useState<Record<string, boolean>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
@@ -133,23 +146,40 @@ export function OnboardingChecklist() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const settings = await apiFetch<SettingsResp>('/api/v1/admin/settings');
|
// Collect every setting key referenced by the checklist so we can
|
||||||
const all = [...settings.data.portSettings, ...settings.data.globalSettings];
|
// batch-resolve them through the full chain in one round-trip.
|
||||||
const byKey = new Map(all.map((r) => [r.key, r.value]));
|
// The `resolved` endpoint reads port→global→env→default, so a
|
||||||
|
// port using env-only credentials still auto-ticks (the old
|
||||||
const isPresent = (v: unknown) => v !== undefined && v !== null && v !== '' && v !== false;
|
// raw `/admin/settings` query missed env fallback entirely).
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const s of STEPS) {
|
||||||
|
if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey);
|
||||||
|
if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
|
||||||
|
}
|
||||||
|
// Manual-checkbox state still lives in the raw system_settings
|
||||||
|
// row (it's a JSON blob, not a per-key registry entry) — keep
|
||||||
|
// fetching it the old way.
|
||||||
|
const [resolved, settings] = await Promise.all([
|
||||||
|
keys.size > 0
|
||||||
|
? apiFetch<ResolvedResp>(
|
||||||
|
`/api/v1/admin/settings/resolved?keys=${Array.from(keys)
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join(',')}`,
|
||||||
|
)
|
||||||
|
: Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp),
|
||||||
|
apiFetch<SettingsResp>('/api/v1/admin/settings'),
|
||||||
|
]);
|
||||||
|
const values = resolved.data.values;
|
||||||
|
const isPresent = (key: string): boolean => Boolean(values[key]?.isSet);
|
||||||
|
|
||||||
const checks: Record<string, boolean> = {};
|
const checks: Record<string, boolean> = {};
|
||||||
const listChecks = await Promise.all(
|
const listChecks = await Promise.all(
|
||||||
STEPS.map(async (s) => {
|
STEPS.map(async (s) => {
|
||||||
if (s.autoCheckSettingKey) {
|
if (s.autoCheckSettingKey) {
|
||||||
return [s.id, isPresent(byKey.get(s.autoCheckSettingKey))] as const;
|
return [s.id, isPresent(s.autoCheckSettingKey)] as const;
|
||||||
}
|
}
|
||||||
if (s.autoCheckSettingKeysAll) {
|
if (s.autoCheckSettingKeysAll) {
|
||||||
return [
|
return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const;
|
||||||
s.id,
|
|
||||||
s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))),
|
|
||||||
] as const;
|
|
||||||
}
|
}
|
||||||
if (s.autoCheckListEndpoint) {
|
if (s.autoCheckListEndpoint) {
|
||||||
try {
|
try {
|
||||||
@@ -165,7 +195,35 @@ export function OnboardingChecklist() {
|
|||||||
for (const [id, done] of listChecks) checks[id] = done;
|
for (const [id, done] of listChecks) checks[id] = done;
|
||||||
setAutoChecks(checks);
|
setAutoChecks(checks);
|
||||||
|
|
||||||
|
// Capture the dominant source per step. For single-key checks this
|
||||||
|
// is the key's source; for multi-key checks we report the
|
||||||
|
// "weakest" source so the rep sees env if any sub-key is env.
|
||||||
|
const sourcesByStep: Record<string, ResolvedValue['source']> = {};
|
||||||
|
const PRIORITY: Record<string, number> = {
|
||||||
|
port: 4,
|
||||||
|
global: 3,
|
||||||
|
env: 2,
|
||||||
|
default: 1,
|
||||||
|
none: 0,
|
||||||
|
};
|
||||||
|
for (const s of STEPS) {
|
||||||
|
if (s.autoCheckSettingKey) {
|
||||||
|
sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source;
|
||||||
|
} else if (s.autoCheckSettingKeysAll) {
|
||||||
|
const sources = s.autoCheckSettingKeysAll
|
||||||
|
.map((k) => values[k]?.source)
|
||||||
|
.filter((x): x is NonNullable<ResolvedValue['source']> => Boolean(x));
|
||||||
|
// Pick the lowest-priority (weakest) source so the rep sees
|
||||||
|
// "env" if the compound has any env-only sub-key.
|
||||||
|
sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0));
|
||||||
|
sourcesByStep[s.id] = sources[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAutoSources(sourcesByStep);
|
||||||
|
|
||||||
// Pull the manual-checkbox state from system_settings.
|
// Pull the manual-checkbox state from system_settings.
|
||||||
|
const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings];
|
||||||
|
const byKey = new Map(allSettings.map((r) => [r.key, r.value]));
|
||||||
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record<string, boolean>;
|
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record<string, boolean>;
|
||||||
setManualChecks(manual);
|
setManualChecks(manual);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,6 +312,20 @@ export function OnboardingChecklist() {
|
|||||||
step.autoCheckSettingKeysAll?.join(' + ') ??
|
step.autoCheckSettingKeysAll?.join(' + ') ??
|
||||||
step.autoCheckListEndpoint}
|
step.autoCheckListEndpoint}
|
||||||
</code>
|
</code>
|
||||||
|
{autoSources[step.id] && autoSources[step.id] !== 'port' ? (
|
||||||
|
<span className="ml-1 text-[10px] text-amber-700">
|
||||||
|
· resolving from{' '}
|
||||||
|
<strong className="font-medium">
|
||||||
|
{autoSources[step.id] === 'env'
|
||||||
|
? 'env fallback'
|
||||||
|
: autoSources[step.id] === 'global'
|
||||||
|
? 'global default'
|
||||||
|
: autoSources[step.id] === 'default'
|
||||||
|
? 'built-in default'
|
||||||
|
: autoSources[step.id]}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Building2, Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -13,7 +17,27 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
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 {
|
interface ClientCompaniesTabProps {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -37,22 +61,45 @@ function formatSince(startDate: string | Date): string {
|
|||||||
return format(d, 'MMM d, yyyy');
|
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 routeParams = useParams<{ portSlug: string }>();
|
||||||
const portSlug = routeParams?.portSlug ?? '';
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
|
|
||||||
if (companies.length === 0) {
|
if (companies.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<>
|
||||||
title="No company memberships"
|
<EmptyState
|
||||||
description="This client is not affiliated with any companies yet. Add a membership from a company's detail page."
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -101,3 +148,104 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,15 +65,36 @@ function formatValueForField(field: string | null, value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Natural-sentence rendering of one audit row. Falls back to terse when
|
/** Natural-sentence rendering of one audit row.
|
||||||
* there's no field to talk about. */
|
*
|
||||||
|
* Behaviour ladder:
|
||||||
|
* 1. Field changed with both old + new values → "set <field> to <new>"
|
||||||
|
* (the inline diff below already shows the old-value strikethrough,
|
||||||
|
* so we don't restate it here; result reads like a sentence rather
|
||||||
|
* than a diff label).
|
||||||
|
* 2. Field changed with only a new value → "set <field> to <new>"
|
||||||
|
* 3. Field changed with neither value (rare; legacy rows) → "<verb>
|
||||||
|
* the <field>"
|
||||||
|
* 4. No field → "<verb> this record"
|
||||||
|
*
|
||||||
|
* Truncation at 60 chars on the inline value keeps long body fields
|
||||||
|
* (notes, descriptions) from blowing out the row — the diff line
|
||||||
|
* below still renders the full value if the rep clicks through.
|
||||||
|
*/
|
||||||
function sentence(row: AuditRow, actor: string): string {
|
function sentence(row: AuditRow, actor: string): string {
|
||||||
const verb = actionVerb(row.action);
|
const verb = actionVerb(row.action);
|
||||||
const field = formatField(row.fieldChanged);
|
const field = formatField(row.fieldChanged);
|
||||||
if (field) {
|
if (!field) return `${actor} ${verb} this record`;
|
||||||
return `${actor} ${verb} the ${field}`;
|
|
||||||
|
const newFormatted =
|
||||||
|
row.newValue !== null && row.newValue !== undefined
|
||||||
|
? formatValueForField(row.fieldChanged, row.newValue)
|
||||||
|
: null;
|
||||||
|
if (newFormatted) {
|
||||||
|
const truncated = newFormatted.length > 60 ? newFormatted.slice(0, 60) + '…' : newFormatted;
|
||||||
|
return `${actor} set ${field} to "${truncated}"`;
|
||||||
}
|
}
|
||||||
return `${actor} ${verb} this record`;
|
return `${actor} ${verb} the ${field}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
Reference in New Issue
Block a user