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)),
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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)})`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user