fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
- 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:
@@ -15,7 +15,13 @@ export const GET = withAuth(async (req, ctx) => {
|
||||
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) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Archive,
|
||||
CircleDollarSign,
|
||||
Plus,
|
||||
Rows3,
|
||||
Rows4,
|
||||
Tag as TagIcon,
|
||||
TagsIcon,
|
||||
} from 'lucide-react';
|
||||
import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -103,8 +94,10 @@ export function BerthList() {
|
||||
// Persisted column visibility + row density + dimension unit - same
|
||||
// pattern as ClientList / InterestList; density falls back to
|
||||
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
|
||||
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } =
|
||||
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
|
||||
const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
|
||||
'berths',
|
||||
BERTH_DEFAULT_HIDDEN,
|
||||
);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
const berthColumns = getBerthColumns(dimensionUnit);
|
||||
|
||||
@@ -187,29 +180,11 @@ export function BerthList() {
|
||||
applyView({ filters: savedFilters, sort: savedSort });
|
||||
}}
|
||||
/>
|
||||
{/* Table-only controls — hidden in card mode (<lg, matching
|
||||
DataTable's table/card switch). The BerthCard ignores row
|
||||
density + dimension unit and renders no column set, so these
|
||||
toggles have no visible effect there and read as broken. */}
|
||||
{/* Table-only controls — hidden in card mode (<md, matching
|
||||
DataTable's table/card switch). The BerthCard ignores the
|
||||
dimension unit + renders no column set, so these toggles have
|
||||
no visible effect there. */}
|
||||
<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
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -238,7 +213,6 @@ export function BerthList() {
|
||||
<DataTable<BerthRow>
|
||||
columns={berthColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
density={density}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
|
||||
@@ -86,7 +86,9 @@ export function BerthOccupancyChip({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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',
|
||||
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)})`}
|
||||
>
|
||||
|
||||
@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
label: 'Pipeline Value',
|
||||
description:
|
||||
'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
|
||||
// breakdown rows have room to breathe alongside the headline numbers,
|
||||
// and the rail stays reserved for reminders / alerts / glance tiles.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Activity, ExternalLink } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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 }) {
|
||||
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.
|
||||
if (interest.archivedAt || interest.outcome) return null;
|
||||
// Hidden when the port disabled pulse chips, or for closed/archived deals.
|
||||
if (!pulseEnabled || interest.archivedAt || interest.outcome) return null;
|
||||
|
||||
const health = computeDealHealth(interest);
|
||||
const tint = PULSE_TINT[health.pulse];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-inpu
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { parsePhone } from '@/lib/i18n/phone';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { CountryFlag } from '@/components/shared/country-flag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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 flagCountry: string | null = (country as string | null) ?? null;
|
||||
if (e164) {
|
||||
const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry);
|
||||
display = parsed.national ?? e164;
|
||||
display = parsed.international ?? parsed.national ?? e164;
|
||||
flagCountry = parsed.country ?? flagCountry;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -142,6 +148,9 @@ export function InlinePhoneField({
|
||||
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')}>
|
||||
{display ?? emptyText}
|
||||
</span>
|
||||
|
||||
@@ -8,11 +8,17 @@ import { apiFetch } from '@/lib/api/client';
|
||||
* Returns true when the given feature flag is enabled for the current port.
|
||||
* 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 }>({
|
||||
queryKey: ['feature-flag', key],
|
||||
queryFn: () => apiFetch(`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}`),
|
||||
queryKey: ['feature-flag', key, defaultValue],
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}&default=${defaultValue}`,
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -102,9 +102,16 @@ export async function computePipelineFunnel(
|
||||
): Promise<PipelineFunnelData> {
|
||||
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
|
||||
// conversions, so polluting the funnel with them gives meaningless math.
|
||||
// Lost is reported separately in the `lost` block.
|
||||
const stageRows = await db
|
||||
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
@@ -112,7 +119,6 @@ export async function computePipelineFunnel(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
between(interests.createdAt, from, to),
|
||||
sql`(${interests.outcome} IS NULL OR ${interests.outcome} = 'won')`,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user