fix: harden pipeline navigation data loading
Some checks failed
Velocity-OS Deployment Pipeline / lint (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / build-and-push (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (agents) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (core) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (media-engine) (push) Has been cancelled
Velocity-OS Deployment Pipeline / sign-images (webos) (push) Has been cancelled
Velocity-OS Deployment Pipeline / notify-ingress (push) Has been cancelled

This commit is contained in:
2026-05-02 13:05:26 +05:30
parent 08a19db035
commit 58628dac35
7 changed files with 169 additions and 57 deletions

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
import { stableArray, unwrapObject } from '@/shared/lib/apiShape';
/**
* useClient360 — fetch unified client entity
@@ -9,8 +10,8 @@ export function useClient360(personId: string) {
const query = useQuery({
queryKey: ['client360', personId],
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
return mapClient360(payload.data);
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapClient360(normalizeSnapshot(payload));
},
staleTime: 30_000,
enabled: !!personId,
@@ -26,8 +27,8 @@ export function useConversations(personId: string) {
const query = useQuery({
queryKey: ['conversations', personId],
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
return mapConversationEvents(payload.data);
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapConversationEvents(normalizeSnapshot(payload));
},
staleTime: 10_000,
enabled: !!personId,
@@ -47,8 +48,8 @@ export function useClientProperties(personId: string) {
const query = useQuery({
queryKey: ['client-properties', personId],
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
return mapPropertyInterests(payload.data);
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapPropertyInterests(normalizeSnapshot(payload));
},
staleTime: 60_000,
enabled: !!personId,
@@ -64,8 +65,8 @@ export function useClientTasks(personId: string) {
const query = useQuery({
queryKey: ['client-tasks', personId],
queryFn: async () => {
const payload = await api.get<Wrapped<Client360Snapshot>>(`/crm/client-360/${personId}`);
return mapTasks(payload.data);
const payload = await api.get<unknown>(`/crm/client-360/${personId}`);
return mapTasks(normalizeSnapshot(payload));
},
staleTime: 30_000,
enabled: !!personId,
@@ -144,11 +145,6 @@ interface Task {
isAIGenerated?: boolean;
}
interface Wrapped<T> {
status: string;
data: T;
}
interface Client360Snapshot {
client_ref?: string;
identity?: {
@@ -203,6 +199,22 @@ interface Client360Snapshot {
recommended_next_actions?: string[];
}
function normalizeSnapshot(payload: unknown): Client360Snapshot {
const snapshot = unwrapObject<Client360Snapshot>(payload);
if (!snapshot) return {};
return {
...snapshot,
identity: snapshot.identity ?? {},
current_lead: snapshot.current_lead ?? {},
active_opportunities: stableArray(snapshot.active_opportunities),
recent_interactions: stableArray(snapshot.recent_interactions),
property_interests: stableArray(snapshot.property_interests),
tasks: stableArray(snapshot.tasks),
risk_flags: stableArray(snapshot.risk_flags),
recommended_next_actions: stableArray(snapshot.recommended_next_actions),
};
}
function scoreToPercent(value: number | null | undefined): number {
const safe = Number(value ?? 0);
const pct = safe <= 1 ? safe * 100 : safe;

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
import { unwrapArray } from '@/shared/lib/apiShape';
/**
* useKanban — Pipeline Pillar kanban board data
@@ -8,11 +9,23 @@ import { api } from '@/shared/lib/apiClient';
export function useKanban() {
const query = useQuery({
queryKey: ['kanban'],
queryFn: () => api.get<KanbanStage[]>('/crm/pipeline/kanban'),
queryFn: async () => {
const payload = await api.get<unknown>('/crm/pipeline/kanban?limit=250&offset=0');
return unwrapArray<RawKanbanStage>(payload).map(normalizeStage);
},
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: 1,
});
return { stages: query.data ?? [], isLoading: query.isLoading };
return {
stages: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
export interface KanbanStage {
@@ -32,3 +45,36 @@ export interface KanbanLead {
lastContactChannel: string;
isVaultActive?: boolean;
}
interface RawKanbanStage {
id?: string;
stage?: string;
status?: string;
label?: string;
emoji?: string;
leads?: unknown;
items?: unknown;
data?: unknown;
}
function normalizeStage(stage: RawKanbanStage): KanbanStage {
const id = String(stage.id ?? stage.stage ?? stage.status ?? 'new');
const leads = unwrapArray<KanbanLead>(stage.leads ?? stage.items ?? stage.data).map(normalizeLead);
return {
id,
label: String(stage.label ?? id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())),
emoji: String(stage.emoji ?? id.slice(0, 1).toUpperCase()),
leads,
};
}
function normalizeLead(lead: Partial<KanbanLead>): KanbanLead {
return {
...lead,
id: String(lead.id),
name: String(lead.name ?? 'Unnamed Client'),
qdScore: Number.isFinite(Number(lead.qdScore)) ? Number(lead.qdScore) : 0,
lastContactRelative: String(lead.lastContactRelative ?? 'No contact yet'),
lastContactChannel: String(lead.lastContactChannel ?? 'crm'),
};
}

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/lib/apiClient';
import { stableArray, unwrapArray, unwrapObject } from '@/shared/lib/apiShape';
/**
* useStudioProperties — Studio Pillar property listing
@@ -8,15 +9,12 @@ export function useStudioProperties() {
const query = useQuery({
queryKey: ['studio-properties'],
queryFn: async () => {
const response = await api.get<InventoryPropertiesResponse | StudioProperty[]>('/inventory/properties');
const rawProperties = Array.isArray(response)
? response
: Array.isArray(response.properties)
? response.properties
: [];
const response = await api.get<unknown>('/inventory/properties?limit=100&offset=0');
const rawProperties = unwrapArray<InventoryPropertyRecord>(response, ['properties']);
return rawProperties.map(mapInventoryProperty);
},
staleTime: 120_000,
refetchOnMount: 'always',
});
return { properties: query.data ?? [], isLoading: query.isLoading };
}
@@ -27,9 +25,10 @@ export function useStudioProperties() {
export function useProperty(propertyId: string) {
const query = useQuery({
queryKey: ['property', propertyId],
queryFn: async () => mapInventoryPropertyDetail(
await api.get<InventoryPropertyRecord>(`/inventory/properties/${propertyId}`)
),
queryFn: async () => {
const payload = await api.get<unknown>(`/inventory/properties/${propertyId}`);
return mapInventoryPropertyDetail(unwrapObject<InventoryPropertyRecord>(payload) ?? {});
},
staleTime: 120_000,
enabled: !!propertyId,
});
@@ -60,10 +59,6 @@ export interface PropertyDetail {
amenities?: string[];
}
interface InventoryPropertiesResponse {
properties?: InventoryPropertyRecord[];
}
interface InventoryPropertyRecord {
property_id?: string;
id?: string;
@@ -77,11 +72,11 @@ interface InventoryPropertyRecord {
area?: string;
address?: string;
};
price_bands?: Array<{ label?: string; min?: number; max?: number; currency?: string }>;
unit_mix?: Array<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>;
price_bands?: unknown;
unit_mix?: unknown;
amenities?: string[];
media?: Array<{ url?: string; thumbnail_url?: string }>;
images?: string[];
media?: unknown;
images?: unknown;
thumbnailUrl?: string;
availableUnits?: number;
}
@@ -92,11 +87,12 @@ function mapLocation(location: InventoryPropertyRecord['location']): string {
}
function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): string | undefined {
if (!Array.isArray(priceBands) || priceBands.length === 0) return undefined;
const mins = priceBands.map((band) => Number(band.min)).filter(Number.isFinite);
const maxes = priceBands.map((band) => Number(band.max)).filter(Number.isFinite);
if (!mins.length && !maxes.length) return priceBands[0]?.label;
const currency = priceBands[0]?.currency ?? 'INR';
const bands = stableArray<{ label?: string; min?: number; max?: number; currency?: string }>(priceBands);
if (bands.length === 0) return undefined;
const mins = bands.map((band) => Number(band.min)).filter(Number.isFinite);
const maxes = bands.map((band) => Number(band.max)).filter(Number.isFinite);
if (!mins.length && !maxes.length) return bands[0]?.label;
const currency = bands[0]?.currency ?? 'INR';
const min = mins.length ? Math.min(...mins) : undefined;
const max = maxes.length ? Math.max(...maxes) : undefined;
if (min !== undefined && max !== undefined) return `${currency} ${min} - ${max}`;
@@ -105,9 +101,11 @@ function mapPriceRange(priceBands: InventoryPropertyRecord['price_bands']): stri
}
function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
const mediaThumb = Array.isArray(record.media) ? record.media.find((item) => item.thumbnail_url || item.url) : undefined;
const availableUnits = Array.isArray(record.unit_mix)
? record.unit_mix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
const media = stableArray<{ url?: string; thumbnail_url?: string }>(record.media);
const unitMix = stableArray<{ configuration?: string; count?: number; available?: number; area?: string; price?: string }>(record.unit_mix);
const mediaThumb = media.find((item) => item.thumbnail_url || item.url);
const availableUnits = unitMix.length
? unitMix.reduce((sum, unit) => sum + Number(unit.available ?? unit.count ?? 0), 0)
: record.availableUnits;
const stableId = record.property_id ?? record.id ?? record.project_name ?? record.name ?? 'unknown-property';
return {
@@ -122,12 +120,12 @@ function mapInventoryProperty(record: InventoryPropertyRecord): StudioProperty {
function mapInventoryPropertyDetail(record: InventoryPropertyRecord): PropertyDetail {
const base = mapInventoryProperty(record);
const primaryUnit = Array.isArray(record.unit_mix) ? record.unit_mix[0] : undefined;
const images = Array.isArray(record.images)
? record.images
: Array.isArray(record.media)
? record.media.map((item) => item.url).filter((url): url is string => Boolean(url))
: [];
const unitMix = stableArray<{ configuration?: string; area?: string; price?: string }>(record.unit_mix);
const media = stableArray<{ url?: string }>(record.media);
const primaryUnit = unitMix[0];
const images = stableArray<string>(record.images).length
? stableArray<string>(record.images)
: media.map((item) => item.url).filter((url): url is string => Boolean(url));
return {
...base,
config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration',