From 103900c58a69e373972ff740d9bd3c6b475ad3e9 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Fri, 1 May 2026 18:43:38 +0530 Subject: [PATCH] fix: restore Velocity-OS pillar data contracts --- core/api/api/routes_crm.py | 94 +++++++++++++++++++ webos/src/control-room/ControlRoom.module.css | 9 +- webos/src/shared/hooks/useStudio.ts | 93 +++++++++++++++++- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/core/api/api/routes_crm.py b/core/api/api/routes_crm.py index 4337288..62dbbdd 100644 --- a/core/api/api/routes_crm.py +++ b/core/api/api/routes_crm.py @@ -205,6 +205,8 @@ class SyntheticSeedRequest(BaseModel): def _serialize_lead(row: Any) -> dict[str, Any]: + if not hasattr(row, "get"): + row = dict(row) score = int(row["score"] or 0) status_label = _normalize_stage(row["kanban_status"]) qualification = row["qualification"] or _infer_qualification(score, row.get("source"), row.get("notes")) @@ -427,6 +429,51 @@ def _merge_lead_sources( ) +def _relative_time_label(value: Any) -> str: + if value is None: + return "No contact yet" + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + else: + parsed = value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + delta = _now() - parsed.astimezone(timezone.utc) + if delta.days > 0: + return f"{delta.days}d ago" + hours = int(delta.total_seconds() // 3600) + if hours > 0: + return f"{hours}h ago" + minutes = int(delta.total_seconds() // 60) + if minutes > 0: + return f"{minutes}m ago" + return "Just now" + + +def _kanban_stage_payload(stage_id: str, label: str, emoji: str, leads: list[dict[str, Any]]) -> dict[str, Any]: + return { + "id": stage_id, + "label": label, + "emoji": emoji, + "leads": [ + { + "id": lead["id"], + "name": lead["name"], + "location": lead.get("unit_interest") or lead.get("budget") or lead.get("source"), + "qdScore": int(lead.get("score") or 0), + "qdDelta": int((lead.get("metadata") or {}).get("qd_delta") or 0), + "lastContactRelative": _relative_time_label(lead.get("updated_at") or lead.get("created_at")), + "lastContactChannel": lead.get("source") or "crm", + "isVaultActive": bool((lead.get("metadata") or {}).get("vault_active")), + } + for lead in leads + ], + } + + async def _fetch_legacy_chat_logs(conn: Any, tenant_id: str, lead_id: str | None = None, channel: str | None = None) -> list[dict[str, Any]]: clauses: list[str] = ["tenant_id = $1"] params: list[Any] = [tenant_id] @@ -1308,6 +1355,53 @@ async def get_kanban_board( return {"status": "ok", "data": board} +@crm_router.get("/pipeline/kanban") +async def get_pipeline_kanban( + request: Request, + user: UserPrincipal = Depends(get_current_user), +) -> list[dict[str, Any]]: + """Velocity-OS Pipeline board contract. + + The simplified WebOS pillar expects raw stage arrays, while the older CRM + board endpoint returns a wrapped administrative payload. Keep this route + explicit so the UI gets exactly the shape it renders. + """ + await _ensure_schema(request) + pool = await _get_pool(request) + tenant_id = _tenant_scope(user) + async with pool.acquire() as conn: + try: + canonical_leads = await _fetch_canonical_leads(conn, tenant_id) + except Exception as exc: + logger.warning("Canonical CRM pipeline bridge unavailable for tenant %s: %s", tenant_id, exc) + canonical_leads = [] + legacy_rows = await conn.fetch( + """ + SELECT id, name, email, phone, source, notes, qualification, score, kanban_status, + budget, unit_interest, metadata, created_at, updated_at + FROM leads + WHERE tenant_id = $1 + ORDER BY score DESC, updated_at DESC, created_at DESC + """, + tenant_id, + ) + leads = _merge_lead_sources(canonical_leads, [_serialize_lead(row) for row in legacy_rows]) + stage_defs = [ + ("new", "New", "N"), + ("qualifying", "Qualifying", "Q"), + ("site_visit", "Site Visit", "V"), + ("negotiation", "Negotiation", "D"), + ("closed", "Closed", "C"), + ] + grouped: dict[str, list[dict[str, Any]]] = {stage_id: [] for stage_id, _, _ in stage_defs} + for lead in leads: + grouped.setdefault(lead.get("stage") or "new", []).append(lead) + return [ + _kanban_stage_payload(stage_id, label, emoji, grouped.get(stage_id, [])) + for stage_id, label, emoji in stage_defs + ] + + @crm_router.put("/kanban/move") async def move_kanban_card( request: Request, diff --git a/webos/src/control-room/ControlRoom.module.css b/webos/src/control-room/ControlRoom.module.css index d571059..08aede6 100644 --- a/webos/src/control-room/ControlRoom.module.css +++ b/webos/src/control-room/ControlRoom.module.css @@ -1,26 +1,27 @@ /* ControlRoom */ -.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } -.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); border-bottom: var(--glass-border); flex-shrink: 0; } +.root { display: flex; flex-direction: column; min-height: 100vh; height: 100vh; overflow: hidden; background: radial-gradient(circle at 16% 10%, rgba(124,58,237,0.22), transparent 34%), radial-gradient(circle at 76% 12%, rgba(59,130,246,0.14), transparent 30%), var(--color-bg-primary); color: var(--color-text-primary); } +.header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); border-bottom: var(--glass-border); background: rgba(9, 13, 24, 0.72); backdrop-filter: blur(22px); flex-shrink: 0; } .headerLeft { display: flex; align-items: center; gap: var(--space-4); } .headerIcon { font-size: 28px; color: var(--color-text-tertiary); } .title { font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; } .subtitle { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: 0; } .body { display: flex; flex: 1; overflow: hidden; } /* Sidebar */ -.sidebar { width: 200px; border-right: var(--glass-border); display: flex; flex-direction: column; padding: var(--space-4); gap: var(--space-1); flex-shrink: 0; } +.sidebar { width: 220px; border-right: var(--glass-border); display: flex; flex-direction: column; padding: var(--space-4); gap: var(--space-1); flex-shrink: 0; background: rgba(7, 11, 20, 0.62); } .sideItem { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: none; border: none; cursor: pointer; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: left; transition: all var(--duration-fast) var(--ease-standard); width: 100%; } .sideItem:hover { background: var(--glass-bg); color: var(--color-text-primary); } .sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); } .sideIcon { font-size: 14px; flex-shrink: 0; } .indicator { position: absolute; left: 0; top: 20%; bottom: 20%; width: 2px; background: var(--color-violet); border-radius: 1px; } /* Content */ -.content { flex: 1; overflow-y: auto; padding: var(--space-8); } +.content { flex: 1; overflow-y: auto; padding: var(--space-8); background: linear-gradient(135deg, rgba(15,23,42,0.48), rgba(2,6,23,0.88)); } .panelWrap { display: flex; flex-direction: column; gap: 0; } /* Panel */ .panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; } .panelTitle { font-size: var(--text-xl); font-weight: var(--font-bold); color: var(--color-text-primary); margin: 0; } .panelSubtitle { font-size: var(--text-sm); color: var(--color-text-secondary); margin: -var(--space-4) 0 0; } .subTitle { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-secondary); margin: 0; } +.muted { font-size: var(--text-sm); color: var(--color-text-secondary); margin: 0; } .adminSection { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-5); background: var(--glass-bg); border: var(--glass-border); border-radius: var(--radius-lg); } /* Service grid */ .serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--space-3); } diff --git a/webos/src/shared/hooks/useStudio.ts b/webos/src/shared/hooks/useStudio.ts index 981eb8e..ed6668e 100644 --- a/webos/src/shared/hooks/useStudio.ts +++ b/webos/src/shared/hooks/useStudio.ts @@ -7,7 +7,15 @@ import { api } from '@/shared/lib/apiClient'; export function useStudioProperties() { const query = useQuery({ queryKey: ['studio-properties'], - queryFn: () => api.get('/inventory/properties'), + queryFn: async () => { + const response = await api.get('/inventory/properties'); + const rawProperties = Array.isArray(response) + ? response + : Array.isArray(response.properties) + ? response.properties + : []; + return rawProperties.map(mapInventoryProperty); + }, staleTime: 120_000, }); return { properties: query.data ?? [], isLoading: query.isLoading }; @@ -19,7 +27,9 @@ export function useStudioProperties() { export function useProperty(propertyId: string) { const query = useQuery({ queryKey: ['property', propertyId], - queryFn: () => api.get(`/inventory/properties/${propertyId}`), + queryFn: async () => mapInventoryPropertyDetail( + await api.get(`/inventory/properties/${propertyId}`) + ), staleTime: 120_000, enabled: !!propertyId, }); @@ -49,3 +59,82 @@ export interface PropertyDetail { images?: string[]; amenities?: string[]; } + +interface InventoryPropertiesResponse { + properties?: InventoryPropertyRecord[]; +} + +interface InventoryPropertyRecord { + property_id?: string; + id?: string; + project_name?: string; + name?: string; + developer_name?: string; + property_type?: string; + location?: { + city?: string; + district?: string; + 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 }>; + amenities?: string[]; + media?: Array<{ url?: string; thumbnail_url?: string }>; + images?: string[]; + thumbnailUrl?: string; + availableUnits?: number; +} + +function mapLocation(location: InventoryPropertyRecord['location']): string { + if (!location) return 'Location pending'; + return [location.district, location.area, location.city, location.address].filter(Boolean).join(', ') || 'Location pending'; +} + +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 min = mins.length ? Math.min(...mins) : undefined; + const max = maxes.length ? Math.max(...maxes) : undefined; + if (min !== undefined && max !== undefined) return `${currency} ${min} - ${max}`; + if (min !== undefined) return `From ${currency} ${min}`; + return `Up to ${currency} ${max}`; +} + +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) + : record.availableUnits; + const stableId = record.property_id ?? record.id ?? record.project_name ?? record.name ?? 'unknown-property'; + return { + id: String(stableId), + name: record.project_name ?? record.name ?? 'Unnamed property', + location: mapLocation(record.location), + priceRange: mapPriceRange(record.price_bands), + thumbnailUrl: record.thumbnailUrl ?? mediaThumb?.thumbnail_url ?? mediaThumb?.url, + availableUnits, + }; +} + +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)) + : []; + return { + ...base, + config: primaryUnit?.configuration ?? record.property_type ?? 'Mixed configuration', + area: primaryUnit?.area ?? 'Area pending', + price: primaryUnit?.price ?? base.priceRange ?? 'Price pending', + description: `${record.developer_name ?? 'Developer'} property in ${base.location}`, + images, + amenities: Array.isArray(record.amenities) ? record.amenities : [], + }; +}