fix: restore Velocity-OS pillar data contracts
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
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:
@@ -205,6 +205,8 @@ class SyntheticSeedRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_lead(row: Any) -> dict[str, Any]:
|
def _serialize_lead(row: Any) -> dict[str, Any]:
|
||||||
|
if not hasattr(row, "get"):
|
||||||
|
row = dict(row)
|
||||||
score = int(row["score"] or 0)
|
score = int(row["score"] or 0)
|
||||||
status_label = _normalize_stage(row["kanban_status"])
|
status_label = _normalize_stage(row["kanban_status"])
|
||||||
qualification = row["qualification"] or _infer_qualification(score, row.get("source"), row.get("notes"))
|
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]]:
|
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"]
|
clauses: list[str] = ["tenant_id = $1"]
|
||||||
params: list[Any] = [tenant_id]
|
params: list[Any] = [tenant_id]
|
||||||
@@ -1308,6 +1355,53 @@ async def get_kanban_board(
|
|||||||
return {"status": "ok", "data": 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")
|
@crm_router.put("/kanban/move")
|
||||||
async def move_kanban_card(
|
async def move_kanban_card(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
/* ControlRoom */
|
/* ControlRoom */
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.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); flex-shrink: 0; }
|
.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); }
|
.headerLeft { display: flex; align-items: center; gap: var(--space-4); }
|
||||||
.headerIcon { font-size: 28px; color: var(--color-text-tertiary); }
|
.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; }
|
.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; }
|
.subtitle { font-size: var(--text-xs); color: var(--color-text-tertiary); margin: 0; }
|
||||||
.body { display: flex; flex: 1; overflow: hidden; }
|
.body { display: flex; flex: 1; overflow: hidden; }
|
||||||
/* Sidebar */
|
/* 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 { 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); }
|
.sideItem:hover { background: var(--glass-bg); color: var(--color-text-primary); }
|
||||||
.sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); }
|
.sideActive { color: var(--color-text-primary) !important; background: var(--glass-bg); }
|
||||||
.sideIcon { font-size: 14px; flex-shrink: 0; }
|
.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; }
|
.indicator { position: absolute; left: 0; top: 20%; bottom: 20%; width: 2px; background: var(--color-violet); border-radius: 1px; }
|
||||||
/* Content */
|
/* 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; }
|
.panelWrap { display: flex; flex-direction: column; gap: 0; }
|
||||||
/* Panel */
|
/* Panel */
|
||||||
.panel { display: flex; flex-direction: column; gap: var(--space-6); max-width: 800px; }
|
.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; }
|
.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; }
|
.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; }
|
.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); }
|
.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 */
|
/* Service grid */
|
||||||
.serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--space-3); }
|
.serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--space-3); }
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { api } from '@/shared/lib/apiClient';
|
|||||||
export function useStudioProperties() {
|
export function useStudioProperties() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['studio-properties'],
|
queryKey: ['studio-properties'],
|
||||||
queryFn: () => api.get<StudioProperty[]>('/inventory/properties'),
|
queryFn: async () => {
|
||||||
|
const response = await api.get<InventoryPropertiesResponse | StudioProperty[]>('/inventory/properties');
|
||||||
|
const rawProperties = Array.isArray(response)
|
||||||
|
? response
|
||||||
|
: Array.isArray(response.properties)
|
||||||
|
? response.properties
|
||||||
|
: [];
|
||||||
|
return rawProperties.map(mapInventoryProperty);
|
||||||
|
},
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
});
|
});
|
||||||
return { properties: query.data ?? [], isLoading: query.isLoading };
|
return { properties: query.data ?? [], isLoading: query.isLoading };
|
||||||
@@ -19,7 +27,9 @@ export function useStudioProperties() {
|
|||||||
export function useProperty(propertyId: string) {
|
export function useProperty(propertyId: string) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['property', propertyId],
|
queryKey: ['property', propertyId],
|
||||||
queryFn: () => api.get<PropertyDetail>(`/inventory/properties/${propertyId}`),
|
queryFn: async () => mapInventoryPropertyDetail(
|
||||||
|
await api.get<InventoryPropertyRecord>(`/inventory/properties/${propertyId}`)
|
||||||
|
),
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
enabled: !!propertyId,
|
enabled: !!propertyId,
|
||||||
});
|
});
|
||||||
@@ -49,3 +59,82 @@ export interface PropertyDetail {
|
|||||||
images?: string[];
|
images?: string[];
|
||||||
amenities?: 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 : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user