fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s

- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 18:27:56 +02:00
parent 39c19b2340
commit 2a7f922a01
8 changed files with 59 additions and 48 deletions

View File

@@ -15,7 +15,13 @@ export const GET = withAuth(async (req, ctx) => {
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)), where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
}); });
return NextResponse.json({ enabled: setting?.value === true }); // `default` applies ONLY when the setting was never written for this
// port (row absent). An explicit stored `false` always disables. Lets
// default-ON settings (e.g. pulse_enabled) gate correctly via
// ?default=true while default-OFF flags keep the old behaviour.
const def = req.nextUrl.searchParams.get('default') === 'true';
const enabled = setting ? setting.value === true : def;
return NextResponse.json({ enabled });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }

View File

@@ -4,16 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
Anchor,
Archive,
CircleDollarSign,
Plus,
Rows3,
Rows4,
Tag as TagIcon,
TagsIcon,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -103,8 +94,10 @@ export function BerthList() {
// Persisted column visibility + row density + dimension unit - same // Persisted column visibility + row density + dimension unit - same
// pattern as ClientList / InterestList; density falls back to // pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked. // 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } = const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); 'berths',
BERTH_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit); const berthColumns = getBerthColumns(dimensionUnit);
@@ -187,29 +180,11 @@ export function BerthList() {
applyView({ filters: savedFilters, sort: savedSort }); applyView({ filters: savedFilters, sort: savedSort });
}} }}
/> />
{/* Table-only controls — hidden in card mode (<lg, matching {/* Table-only controls — hidden in card mode (<md, matching
DataTable's table/card switch). The BerthCard ignores row DataTable's table/card switch). The BerthCard ignores the
density + dimension unit and renders no column set, so these dimension unit + renders no column set, so these toggles have
toggles have no visible effect there and read as broken. */} no visible effect there. */}
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -238,7 +213,6 @@ export function BerthList() {
<DataTable<BerthRow> <DataTable<BerthRow>
columns={berthColumns} columns={berthColumns}
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
density={density}
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
pagination={{ pagination={{

View File

@@ -86,7 +86,9 @@ export function BerthOccupancyChip({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors', 'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
compact && 'max-w-[200px]', // Cap tight on narrow viewports, but give the name room on desktop
// so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
compact && 'max-w-[200px] md:max-w-[460px]',
)} )}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
> >

View File

@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
label: 'Pipeline Value', label: 'Pipeline Value',
description: description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.', 'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
render: (range) => <PipelineValueTile range={range} />, // Current-state snapshot: pipeline value = sum across ALL active deals,
// not "added in the selected window". Don't thread the range (UAT
// 2026-06-03 — windowing it dropped older deals + confused the headline).
render: () => <PipelineValueTile />,
// Lives in the chart grid (not the narrow rail) so the per-stage // Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers, // breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles. // and the rail stays reserved for reminders / alerts / glance tiles.

View File

@@ -6,6 +6,7 @@ import { Activity, ExternalLink } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health'; import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = { const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
@@ -31,9 +32,13 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
*/ */
export function DealPulseChip({ interest }: { interest: DealHealthInput }) { export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Master toggle: Admin → Pulse → "Show deal pulse chips" (pulse_enabled).
// Defaults ON (chip visible) when the port hasn't set it; hidden only when
// explicitly disabled.
const pulseEnabled = useFeatureFlag('pulse_enabled', true);
// Closed / archived deals don't get a pulse - UX would be confusing. // Hidden when the port disabled pulse chips, or for closed/archived deals.
if (interest.archivedAt || interest.outcome) return null; if (!pulseEnabled || interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest); const health = computeDealHealth(interest);
const tint = PULSE_TINT[health.pulse]; const tint = PULSE_TINT[health.pulse];

View File

@@ -7,6 +7,7 @@ import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-inpu
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { parsePhone } from '@/lib/i18n/phone'; import { parsePhone } from '@/lib/i18n/phone';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { CountryFlag } from '@/components/shared/country-flag';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface InlinePhoneFieldProps { interface InlinePhoneFieldProps {
@@ -122,11 +123,16 @@ export function InlinePhoneField({
); );
} }
// Display: prefer the parsed national format (more readable than raw E.164). // Display: international format so the dialing/country code is visible
// ('+590 690 58 59 18'), paired with a country flag for quick scanning.
// The bare national format ('0690 58 59 18') hid which country the number
// belonged to (UAT 2026-06-03).
let display: string | null = null; let display: string | null = null;
let flagCountry: string | null = (country as string | null) ?? null;
if (e164) { if (e164) {
const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry); const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry);
display = parsed.national ?? e164; display = parsed.international ?? parsed.national ?? e164;
flagCountry = parsed.country ?? flagCountry;
} }
return ( return (
@@ -142,6 +148,9 @@ export function InlinePhoneField({
className, className,
)} )}
> >
{display && flagCountry ? (
<CountryFlag code={flagCountry} className="h-2.5 w-3.5 shrink-0" decorative />
) : null}
<span className={cn('flex-1', !display && 'text-muted-foreground')}> <span className={cn('flex-1', !display && 'text-muted-foreground')}>
{display ?? emptyText} {display ?? emptyText}
</span> </span>

View File

@@ -8,11 +8,17 @@ import { apiFetch } from '@/lib/api/client';
* Returns true when the given feature flag is enabled for the current port. * Returns true when the given feature flag is enabled for the current port.
* Result is cached for 5 minutes. * Result is cached for 5 minutes.
*/ */
export function useFeatureFlag(key: string): boolean { export function useFeatureFlag(key: string, defaultValue = false): boolean {
const { data } = useQuery<{ enabled: boolean }>({ const { data } = useQuery<{ enabled: boolean }>({
queryKey: ['feature-flag', key], queryKey: ['feature-flag', key, defaultValue],
queryFn: () => apiFetch(`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}`), queryFn: () =>
apiFetch(
`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}&default=${defaultValue}`,
),
staleTime: 300_000, // 5 min staleTime: 300_000, // 5 min
}); });
return data?.enabled ?? false; // `defaultValue` is the fallback both while loading AND when the port has
// never written the setting — so default-ON flags (chip visible) don't
// flash hidden.
return data?.enabled ?? defaultValue;
} }

View File

@@ -102,9 +102,16 @@ export async function computePipelineFunnel(
): Promise<PipelineFunnelData> { ): Promise<PipelineFunnelData> {
const { from, to } = rangeToBounds(range); const { from, to } = rangeToBounds(range);
// The pipeline funnel is a CURRENT-STATE snapshot: it shows the live
// distribution of active interests across stages, NOT a created-in-range
// cohort. We deliberately do NOT filter the stage counts by createdAt —
// doing so dropped older late-stage deals (reservation/contract) out of
// shorter windows once real origination dates were backfilled, collapsing
// the funnel to the early stages (UAT 2026-06-03). The selected date range
// still scopes the `lost` leakage block below.
//
// Stage counts EXCLUDE lost/cancelled outcomes - those never become // Stage counts EXCLUDE lost/cancelled outcomes - those never become
// conversions, so polluting the funnel with them gives meaningless math. // conversions, so polluting the funnel with them gives meaningless math.
// Lost is reported separately in the `lost` block.
const stageRows = await db const stageRows = await db
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` }) .select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
.from(interests) .from(interests)
@@ -112,7 +119,6 @@ export async function computePipelineFunnel(
and( and(
eq(interests.portId, portId), eq(interests.portId, portId),
isNull(interests.archivedAt), isNull(interests.archivedAt),
between(interests.createdAt, from, to),
sql`(${interests.outcome} IS NULL OR ${interests.outcome} = 'won')`, sql`(${interests.outcome} IS NULL OR ${interests.outcome} = 'won')`,
), ),
) )