feat(PORT-004A): add workspace bootstrap and scoped settings

This commit is contained in:
Bu5hm4nn
2026-03-24 20:18:12 +01:00
parent 9d1a2f3fe8
commit 75f8e0a282
9 changed files with 335 additions and 17 deletions

View File

@@ -18,6 +18,15 @@ NAV_ITEMS: list[tuple[str, str, str]] = [
]
def nav_items(workspace_id: str | None = None) -> list[tuple[str, str, str]]:
if not workspace_id:
return NAV_ITEMS
return [
("overview", f"/{workspace_id}", "Overview"),
("settings", f"/{workspace_id}/settings", "Settings"),
]
def demo_spot_price() -> float:
return 215.0
@@ -166,7 +175,7 @@ def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
@contextmanager
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]:
def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]:
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
# Header must be at page level, not inside container
@@ -179,7 +188,7 @@ def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.colum
ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50")
ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-2 max-sm:flex-wrap"):
for key, href, label in NAV_ITEMS:
for key, href, label in nav_items(workspace_id):
active = key == current
link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + (
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from app.components import PortfolioOverview
from app.models.portfolio import get_portfolio_repository
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.runtime import get_data_service
@@ -31,10 +33,66 @@ def _alert_badge_classes(severity: str) -> str:
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
def _render_workspace_recovery(title: str, message: str) -> None:
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(message).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
"rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200"
)
@ui.page("/")
def welcome_page(request: Request):
repo = get_workspace_repository()
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"):
with ui.card().classes(
"w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Vault Dashboard").classes("text-sm font-semibold uppercase tracking-[0.2em] text-sky-600")
ui.label("Create a private workspace URL").classes("text-4xl font-bold text-slate-900 dark:text-slate-50")
ui.label(
"Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("items-center gap-4 pt-4"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.label("You can always create a fresh workspace later if a link is lost.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
@ui.page("/overview")
async def overview_page() -> None:
config = get_portfolio_repository().load()
def legacy_overview_page(request: Request):
repo = get_workspace_repository()
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
return RedirectResponse(url="/", status_code=307)
@ui.page("/{workspace_id}")
@ui.page("/{workspace_id}/overview")
async def overview_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
_render_workspace_recovery(
"Workspace not found",
"The workspace link looks missing or expired. Create a new workspace or return to the welcome page.",
)
return
config = repo.load_portfolio_config(workspace_id)
data_service = get_data_service()
symbol = data_service.default_symbol
quote = await data_service.get_quote(symbol)
@@ -57,6 +115,7 @@ async def overview_page() -> None:
"Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
"overview",
workspace_id=workspace_id,
):
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
from app.models.portfolio import PortfolioConfig
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
from app.pages.common import dashboard_page
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.settings_status import save_status_text
@@ -16,11 +19,40 @@ def _alert_badge_classes(severity: str) -> str:
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
def _render_workspace_recovery() -> None:
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
ui.label("Workspace not found").classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(
"The requested workspace is unavailable. Start a new workspace or return to the welcome page."
).classes("text-base text-slate-500 dark:text-slate-400")
with ui.row().classes("mx-auto gap-3"):
ui.link("Get started", "/workspaces/bootstrap").classes(
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
)
ui.link("Go to welcome page", "/").classes(
"rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200"
)
@ui.page("/settings")
def settings_page() -> None:
"""Settings page with persistent portfolio configuration."""
repo = get_portfolio_repository()
config = repo.load()
def legacy_settings_page(request: Request):
repo = get_workspace_repository()
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
if workspace_id and repo.workspace_exists(workspace_id):
return RedirectResponse(url=f"/{workspace_id}/settings", status_code=307)
return RedirectResponse(url="/", status_code=307)
@ui.page("/{workspace_id}/settings")
def settings_page(workspace_id: str) -> None:
"""Settings page with workspace-scoped persistent portfolio configuration."""
workspace_repo = get_workspace_repository()
if not workspace_repo.workspace_exists(workspace_id):
_render_workspace_recovery()
return
config = workspace_repo.load_portfolio_config(workspace_id)
alert_service = AlertService()
syncing_entry_basis = False
@@ -61,6 +93,7 @@ def settings_page() -> None:
"Settings",
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
"settings",
workspace_id=workspace_id,
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
with ui.card().classes(
@@ -326,7 +359,7 @@ def settings_page() -> None:
def save_settings() -> None:
try:
new_config = build_preview_config()
repo.save(new_config)
workspace_repo.save_portfolio_config(workspace_id, new_config)
render_alert_state()
status.set_text(save_status_text(new_config))