chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -19,7 +19,7 @@ interface KpiResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rail-sized KPI tile — single number, label, and a click-
|
||||
* Compact rail-sized KPI tile - single number, label, and a click-
|
||||
* through to the interests pipeline. Reuses the existing dashboard KPIs
|
||||
* endpoint so we don't pay an extra round-trip.
|
||||
*/
|
||||
@@ -36,7 +36,7 @@ export function ActiveDealsTile() {
|
||||
return (
|
||||
<Card>
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
|
||||
CardHeader sits above). The `sm:` variants are required — without
|
||||
CardHeader sits above). The `sm:` variants are required - without
|
||||
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
|
||||
the top edge. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
|
||||
@@ -57,7 +57,7 @@ export function ActiveDealsTile() {
|
||||
</div>
|
||||
<Link
|
||||
// Next typedRoutes can't infer dynamic-segment routes from a template
|
||||
// literal — cast through unknown rather than `any` so the lint rule
|
||||
// literal - cast through unknown rather than `any` so the lint rule
|
||||
// is satisfied while the runtime href is still correct.
|
||||
href={`/${portSlug}/interests` as unknown as Route}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ActivityItem {
|
||||
label: string | null;
|
||||
userId: string | null;
|
||||
/** Server-resolved actor display name (from user_profiles). When null,
|
||||
* the actor row no longer exists — render falls back to a "Unknown
|
||||
* the actor row no longer exists - render falls back to a "Unknown
|
||||
* user" sentinel rather than the raw UUID prefix. */
|
||||
actorName: string | null;
|
||||
fieldChanged: string | null;
|
||||
@@ -52,6 +52,55 @@ function humanizeFieldName(name: string): string {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Entity type alias map for the feed labels. Most types humanize fine
|
||||
* via `humanizeFieldName`, but a few read awkwardly ("Residential
|
||||
* Client" is clearer than the raw enum, notes flatten to their parent). */
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
residential_client: 'Residential client',
|
||||
residential_interest: 'Residential interest',
|
||||
berth_reservation: 'Berth reservation',
|
||||
berth_maintenance_log: 'Berth maintenance',
|
||||
berth_recommendation: 'Berth recommendation',
|
||||
client_note: 'Client note',
|
||||
yacht_note: 'Yacht note',
|
||||
company_note: 'Company note',
|
||||
interest_note: 'Interest note',
|
||||
interest_qualification: 'Interest qualification',
|
||||
document_send: 'Document send',
|
||||
document_folder: 'Document folder',
|
||||
document_template: 'Document template',
|
||||
documentTemplate: 'Document template',
|
||||
form_template: 'Form template',
|
||||
report_template: 'Report template',
|
||||
email_account: 'Email account',
|
||||
email_message: 'Email message',
|
||||
user_email_change: 'Email change',
|
||||
custom_field_definition: 'Custom field',
|
||||
custom_field_values: 'Custom field',
|
||||
expense_export: 'Expense export',
|
||||
gdpr_export: 'GDPR export',
|
||||
qualification_criterion: 'Qualification criterion',
|
||||
website_submission: 'Website submission',
|
||||
webhook_inbound: 'Inbound webhook',
|
||||
webhook_delivery: 'Webhook delivery',
|
||||
audit_log: 'Audit log',
|
||||
portal_user: 'Portal user',
|
||||
portal_session: 'Portal session',
|
||||
portal_auth_token: 'Portal token',
|
||||
client_contact: 'Client contact',
|
||||
clientContact: 'Client contact',
|
||||
clientAddress: 'Client address',
|
||||
companyAddress: 'Company address',
|
||||
clientRelationship: 'Client relationship',
|
||||
company_membership: 'Company membership',
|
||||
crm_invite: 'CRM invite',
|
||||
queue_job: 'Queue job',
|
||||
super_admin: 'Super admin',
|
||||
};
|
||||
function humanizeEntityType(type: string): string {
|
||||
return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type);
|
||||
}
|
||||
|
||||
/** Map enum-typed field values to their canonical human labels. The audit
|
||||
* log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the
|
||||
* feed should read like `10% Deposit`, not the wire value. */
|
||||
@@ -85,13 +134,13 @@ function normalizeEnumValue(field: string, value: unknown): unknown {
|
||||
* count; nulls / empty render as em-dash. */
|
||||
function shortValue(value: unknown, fieldContext?: string): string {
|
||||
if (fieldContext) value = normalizeEnumValue(fieldContext, value);
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) return '—';
|
||||
if (entries.length === 0) return '-';
|
||||
return entries
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
@@ -199,7 +248,7 @@ function ActivityFeedInner() {
|
||||
|
||||
// A1: permission_denied rows on the activity feed render as a bare
|
||||
// action badge with no entity name (they target `admin.X` with empty
|
||||
// entityId). They're noise for the rep — keep them in the audit log
|
||||
// entityId). They're noise for the rep - keep them in the audit log
|
||||
// page but hide them from the dashboard feed.
|
||||
const items = (data ?? []).filter((i) => i.action !== 'permission_denied');
|
||||
|
||||
@@ -245,18 +294,23 @@ function ActivityFeedInner() {
|
||||
space between them. */}
|
||||
<span className="text-muted-foreground/60 mx-1.5">·</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">
|
||||
{item.entityType}
|
||||
{humanizeEntityType(item.entityType)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium capitalize">{item.entityType}</span>
|
||||
{item.entityId && (
|
||||
<span className="ml-1 text-muted-foreground font-mono text-xs">
|
||||
{item.entityId.slice(0, 8)}
|
||||
// No resolvable label - either the entity was
|
||||
// deleted or the type isn't in the server-side
|
||||
// resolver yet. Either way we never expose a
|
||||
// UUID fragment: it reads as noise to the rep
|
||||
// and leaks an internal identifier.
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeEntityType(item.entityType)}
|
||||
{item.entityId ? (
|
||||
<span className="ml-1 text-muted-foreground text-xs font-normal">
|
||||
(removed)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{diffLine ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Berth-demand widget — ranks berths by active interest count, with a
|
||||
* Berth-demand widget - ranks berths by active interest count, with a
|
||||
* horizontal bar per row encoding magnitude relative to the leader.
|
||||
* Matches the standard CardHeader / CardContent layout of its dashboard
|
||||
* siblings; the bars (not chrome) do the visual work.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Globe } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { CountryFlag } from '@/components/shared/country-flag';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -32,7 +33,7 @@ interface ClientsByCountryResponse {
|
||||
* into a specific market. Country names render via the existing
|
||||
* locale-aware helper; unknown ISO codes fall back to the raw code.
|
||||
*
|
||||
* Variant (b) of the master-doc design — a true choropleth would need
|
||||
* Variant (b) of the master-doc design - a true choropleth would need
|
||||
* a heavier viz lib (react-simple-maps + topojson) and pushes us to
|
||||
* the chart-library migration agenda. Variant (a) ships now; the
|
||||
* world-map variant can land alongside the recharts→ECharts pass.
|
||||
@@ -100,13 +101,11 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
|
||||
className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5"
|
||||
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-xs font-mono uppercase text-muted-foreground">
|
||||
{row.country}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
|
||||
<span className="truncate text-sm">{name}</span>
|
||||
</div>
|
||||
{/* Mini bar — same `BerthHeatWidget` idiom: a thin
|
||||
{/* Mini bar - same `BerthHeatWidget` idiom: a thin
|
||||
background track with a coloured fill. The count
|
||||
sits on the right so the eye can read both the
|
||||
bar shape and the precise number. */}
|
||||
|
||||
@@ -33,23 +33,35 @@ import {
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
import type { DashboardWidget } from './widget-registry';
|
||||
import type { DashboardWidget, WidgetGroup } from './widget-registry';
|
||||
|
||||
// The dashboard renders widgets in three independent visual regions at
|
||||
// xl (1280+): charts (main column), rails (right aside), feed (full-
|
||||
// width). Below xl, all three regions stack into one visual column -
|
||||
// from the rep's eye it reads as a single ordered list, so the modal
|
||||
// flattens its sortable in that tier. At xl it splits into three
|
||||
// region-scoped sortables to match the actual side-by-side layout.
|
||||
const GROUP_LABELS: Record<WidgetGroup, string> = {
|
||||
chart: 'Charts',
|
||||
rail: 'Side rail',
|
||||
feed: 'Activity',
|
||||
};
|
||||
const GROUP_ORDER: readonly WidgetGroup[] = ['chart', 'rail', 'feed'];
|
||||
|
||||
/**
|
||||
* Combined visibility + reorder picker for the dashboard header. Two
|
||||
* sections in one modal:
|
||||
* Combined visibility + reorder picker for the dashboard header.
|
||||
*
|
||||
* 1. "On dashboard" — visible widgets, each row with a drag handle
|
||||
* (reorder via dnd-kit single SortableContext, no buckets); flipping
|
||||
* a switch off moves the row to section 2.
|
||||
* 2. "Hidden" — widgets currently off; flipping a switch on appends to
|
||||
* the bottom of section 1.
|
||||
* The dashboard renders widgets in three independent visual regions -
|
||||
* Charts (main column), Side rail (right aside), Activity (full-width
|
||||
* feed). A drag across regions can't change the visual outcome, so the
|
||||
* modal exposes one sortable list per region instead of a single flat
|
||||
* list that silently fails on cross-region moves. Toggling a widget off
|
||||
* moves it to the "Hidden" section; toggling on appends it to the
|
||||
* bottom of its native region.
|
||||
*
|
||||
* Both visibility toggles and order changes commit optimistically via
|
||||
* `useDashboardWidgets` so the dashboard reflows in the background and
|
||||
* the rep can keep editing. The "Rearrange" button on the header is
|
||||
* gone — order lives here too now, keeping all dashboard layout
|
||||
* controls in one place.
|
||||
* the rep can keep editing.
|
||||
*/
|
||||
export function CustomizeWidgetsMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -57,6 +69,7 @@ export function CustomizeWidgetsMenu() {
|
||||
allWidgets,
|
||||
visibleWidgets,
|
||||
visibility,
|
||||
isXlLayout,
|
||||
setVisible,
|
||||
setAll,
|
||||
setOrder,
|
||||
@@ -79,7 +92,53 @@ export function CustomizeWidgetsMenu() {
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
function onDragEnd(event: DragEndEvent) {
|
||||
// Visible widgets split per region. Empty regions render nothing so
|
||||
// we don't show an "On dashboard / Side rail (0)" tease.
|
||||
const visibleByGroup: Record<WidgetGroup, DashboardWidget[]> = {
|
||||
chart: visibleWidgets.filter((w) => w.group === 'chart'),
|
||||
rail: visibleWidgets.filter((w) => w.group === 'rail'),
|
||||
feed: visibleWidgets.filter((w) => w.group === 'feed'),
|
||||
};
|
||||
|
||||
// A drag inside group X only moves widgets within that group. Rebuild
|
||||
// the flat order by walking `visibleWidgets` in its current sequence
|
||||
// and replacing each group-X slot with the next id from the reordered
|
||||
// group list. This preserves the relative position of every other
|
||||
// widget - only the dragged group's internal order changes.
|
||||
function reorderGroup(group: WidgetGroup, oldIndex: number, newIndex: number) {
|
||||
const groupIds = visibleByGroup[group].map((w) => w.id);
|
||||
if (
|
||||
oldIndex < 0 ||
|
||||
newIndex < 0 ||
|
||||
oldIndex >= groupIds.length ||
|
||||
newIndex >= groupIds.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reordered = arrayMove(groupIds, oldIndex, newIndex);
|
||||
let cursor = 0;
|
||||
const nextOrder = visibleWidgets.map((w) =>
|
||||
w.group === group ? (reordered[cursor++] ?? w.id) : w.id,
|
||||
);
|
||||
setOrder(nextOrder);
|
||||
}
|
||||
|
||||
function makeDragEndHandler(group: WidgetGroup) {
|
||||
return (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const ids = visibleByGroup[group].map((w) => w.id);
|
||||
const oldIndex = ids.indexOf(String(active.id));
|
||||
const newIndex = ids.indexOf(String(over.id));
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
reorderGroup(group, oldIndex, newIndex);
|
||||
};
|
||||
}
|
||||
|
||||
// Flat reorder used by the stacked layout (< xl). One SortableContext
|
||||
// over every visible widget; drops persist via setOrder, which the
|
||||
// hook routes to the mobile order field.
|
||||
function onFlatDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const ids = visibleWidgets.map((w) => w.id);
|
||||
@@ -97,24 +156,64 @@ export function CustomizeWidgetsMenu() {
|
||||
Customize
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden
|
||||
widgets leave no empty space - the layout reflows to fill the available width.
|
||||
{isXlLayout
|
||||
? 'Editing the desktop layout - drag a widget to reorder it within its region.'
|
||||
: 'Editing the stacked layout for this device - drag a widget to reorder. Your desktop arrangement is saved separately.'}{' '}
|
||||
Toggle the switch to show or hide. Hidden widgets leave no empty space - the layout
|
||||
reflows to fill the available width.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toggle + reorder list. Capped at ~60vh with internal scroll so
|
||||
the modal doesn't push the action footer off-screen. */}
|
||||
the modal doesn't push the action footer off-screen. The
|
||||
layout matches what the rep is actually seeing: at xl the
|
||||
dashboard renders charts | rails | feed as three independent
|
||||
slots, so the picker exposes three region-scoped sortables.
|
||||
Below xl everything stacks into one column visually, so the
|
||||
picker collapses to a single flat sortable that reorders
|
||||
across the whole list. */}
|
||||
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
|
||||
{visibleWidgets.length > 0 ? (
|
||||
{isXlLayout ? (
|
||||
GROUP_ORDER.map((group) => {
|
||||
const widgets = visibleByGroup[group];
|
||||
if (widgets.length === 0) return null;
|
||||
return (
|
||||
<Section key={group} title={`${GROUP_LABELS[group]} (${widgets.length})`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={makeDragEndHandler(group)}
|
||||
>
|
||||
<SortableContext
|
||||
items={widgets.map((w) => w.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{widgets.map((w, idx) => (
|
||||
<SortableVisibleRow
|
||||
key={w.id}
|
||||
widget={w}
|
||||
position={idx + 1}
|
||||
disabled={isSaving}
|
||||
onToggle={(checked) => setVisible(w.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Section>
|
||||
);
|
||||
})
|
||||
) : visibleWidgets.length > 0 ? (
|
||||
<Section title={`On dashboard (${visibleWidgets.length})`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragEnd={onFlatDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={visibleWidgets.map((w) => w.id)}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DashboardShell({
|
||||
const feed = visibleWidgets.filter((w) => w.group === 'feed');
|
||||
|
||||
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
|
||||
// useTablePreferences elsewhere — usually a cache hit, so no extra
|
||||
// useTablePreferences elsewhere - usually a cache hit, so no extra
|
||||
// request. When the page server-prefetches the first name we seed it
|
||||
// here via `initialData` so the cache is warm before the post-mount
|
||||
// fetch resolves, eliminating the "Welcome back → Hello, Matt" flash.
|
||||
@@ -107,12 +107,12 @@ export function DashboardShell({
|
||||
|
||||
// Greeting word is computed in a useEffect so the rendered HTML can't lock
|
||||
// to the server's clock during hydration. Until the effect fires, the
|
||||
// header reads "Welcome" — a neutral phrase that's correct at every hour
|
||||
// header reads "Welcome" - a neutral phrase that's correct at every hour
|
||||
// and never produces a hydration warning. `clientGreeting` flips to the
|
||||
// local-time-aware phrasing once the component has mounted.
|
||||
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
// setState here is intentional — we delay the time-aware greeting
|
||||
// setState here is intentional - we delay the time-aware greeting
|
||||
// until after hydration to avoid SSR/client clock mismatch.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setClientGreeting(timeOfDayGreeting());
|
||||
@@ -149,7 +149,7 @@ export function DashboardShell({
|
||||
<div className="space-y-6">
|
||||
{/* Mobile-only greeting strip. The shared PageHeader is hidden
|
||||
below `sm` (its title is normally duplicated by the topbar),
|
||||
so we render the welcome message inline here for mobile —
|
||||
so we render the welcome message inline here for mobile -
|
||||
keeps the personalized touch from desktop without polluting
|
||||
the topbar (which stays "Dashboard" for wayfinding). */}
|
||||
<div className="sm:hidden">
|
||||
@@ -170,8 +170,8 @@ export function DashboardShell({
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
<ExportDashboardPdfButton />
|
||||
<CustomizeWidgetsMenu />
|
||||
<ExportDashboardPdfButton />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -232,7 +232,7 @@ export function DashboardShell({
|
||||
/**
|
||||
* Placeholder shown when the rep has hidden every widget. Without this,
|
||||
* the dashboard collapses to just the gradient header strip and looks
|
||||
* like a broken page — this hints at the "Customize" button to bring
|
||||
* like a broken page - this hints at the "Customize" button to bring
|
||||
* widgets back.
|
||||
*/
|
||||
function EmptyDashboardHint() {
|
||||
|
||||
@@ -25,7 +25,7 @@ interface HotDealsResponse {
|
||||
|
||||
// Local label map intentionally narrowed to the stages this widget
|
||||
// surfaces. Keys MUST match the canonical DB values for the 7-stage
|
||||
// pipeline (post-2026-05 refactor) — the reporting audit caught typos
|
||||
// pipeline (post-2026-05 refactor) - the reporting audit caught typos
|
||||
// that broke the rank ladder server-side AND rendered raw enum to the user.
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
contract: 'Contract',
|
||||
|
||||
@@ -59,7 +59,7 @@ const STAGE_BAR_CLASS: Record<string, string> = {
|
||||
export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
|
||||
// Range query-string is keyed on the slug ('7d' / 'custom-2026-01-01...').
|
||||
// When range is undefined, the tile falls back to the "all active deals"
|
||||
// snapshot — preserves the old behaviour for callers that don't yet
|
||||
// snapshot - preserves the old behaviour for callers that don't yet
|
||||
// thread range through.
|
||||
const slug = range ? rangeToSlug(range) : null;
|
||||
const qs = slug ? `?range=${encodeURIComponent(slug)}` : '';
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SourceConversionChart() {
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Inline bar — keeps the widget compact and lets eight
|
||||
{/* Inline bar - keeps the widget compact and lets eight
|
||||
rows share the same vertical space a Recharts plot
|
||||
would use for two. */}
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
|
||||
@@ -86,7 +86,7 @@ export function TimezoneDriftBanner() {
|
||||
try {
|
||||
window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true');
|
||||
} catch {
|
||||
// Non-fatal — we just don't persist the dismissal.
|
||||
// Non-fatal - we just don't persist the dismissal.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* Compact "Website at a glance" tile for the main sales dashboard. Shows
|
||||
* pageviews for the dashboard's current range + active visitors right
|
||||
* now + a deep-link to the full /website-analytics page. Soft-fails
|
||||
* (renders nothing) when Umami isn't configured for this port — the
|
||||
* (renders nothing) when Umami isn't configured for this port - the
|
||||
* configure-prompt lives on the dedicated page, not the dashboard.
|
||||
*
|
||||
* When an Umami call fails (auth, network, shape) the tile renders a
|
||||
* dash "—" instead of "0" so the rep can tell error from no-data.
|
||||
* dash "-" instead of "0" so the rep can tell error from no-data.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
@@ -49,7 +49,7 @@ export function WebsiteGlanceTile({ range = '30d' }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Umami v3 returns flat numbers — `data?.data?.pageviews` is a number,
|
||||
// Umami v3 returns flat numbers - `data?.data?.pageviews` is a number,
|
||||
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
|
||||
// comparison values into a sibling `comparison` block.
|
||||
const pageviews = stats.data?.data?.pageviews;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dashboard widget registry — the single source of truth for which
|
||||
* Dashboard widget registry - the single source of truth for which
|
||||
* widgets exist, what they're called, where they live, and what they
|
||||
* default to. The DashboardShell loops over this; the settings UI also
|
||||
* loops over this. Adding a new widget = adding one entry here.
|
||||
@@ -76,7 +76,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed';
|
||||
export type WidgetIntegration = 'umami' | 'documenso';
|
||||
|
||||
export interface DashboardWidget {
|
||||
/** Stable persistence key. Don't rename — old preferences would break. */
|
||||
/** Stable persistence key. Don't rename - old preferences would break. */
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -92,7 +92,7 @@ export interface DashboardWidget {
|
||||
/**
|
||||
* Some widgets self-gate (e.g. WebsiteGlanceTile renders null when
|
||||
* Umami isn't configured). When `true`, the settings UI still shows
|
||||
* the toggle so admins can enable it once the integration is wired —
|
||||
* the toggle so admins can enable it once the integration is wired -
|
||||
* but the widget itself decides whether to render content.
|
||||
*/
|
||||
selfGates?: boolean;
|
||||
@@ -106,7 +106,7 @@ export interface DashboardWidget {
|
||||
|
||||
export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
// ── KPI tiles (rail) ────────────────────────────────────────────────
|
||||
// Off by default — keep the existing dashboard layout unchanged for
|
||||
// Off by default - keep the existing dashboard layout unchanged for
|
||||
// users on first paint after the upgrade; reps can flip them on from
|
||||
// the Customize menu.
|
||||
{
|
||||
@@ -166,10 +166,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
{
|
||||
id: 'source_conversion',
|
||||
label: 'Source Conversion',
|
||||
description: 'Win rate per lead source — which channels deliver buyers, not just leads.',
|
||||
description: 'Win rate per lead source - which channels deliver buyers, not just leads.',
|
||||
render: () => <SourceConversionChart />,
|
||||
group: 'chart',
|
||||
// Flipped on 2026-05-14 — investor-facing conversion-funnel-by-source
|
||||
// Flipped on 2026-05-14 - investor-facing conversion-funnel-by-source
|
||||
// surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage
|
||||
// (clients.source_inquiry_id) added in migration 0065.
|
||||
defaultVisible: true,
|
||||
@@ -189,7 +189,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
description:
|
||||
'Per-country distribution of the active client book. Click a row to filter the clients list by country.',
|
||||
render: () => <ClientsByCountryWidget />,
|
||||
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard — compact
|
||||
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard - compact
|
||||
// ranked list with mini-bars. Variant (a) per the master-doc design;
|
||||
// the world-map variant lands alongside the recharts→ECharts pass.
|
||||
group: 'rail',
|
||||
|
||||
Reference in New Issue
Block a user