From 51092e8e4eb7c712d7f3bcda832bcee7100cbfbf Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Wed, 18 Feb 2026 10:36:02 -0800 Subject: [PATCH 01/24] Restructure support request flows and app assets. Replace complaint/refund assets with support-request and support-console resources, update pipeline transformation, and refresh bundle configuration for the new app layout. Co-authored-by: Cursor --- apps/refund-manager/app.yaml | 8 - apps/refund-manager/app/__init__.py | 0 apps/refund-manager/app/databricks_events.py | 76 - apps/refund-manager/app/db.py | 24 - apps/refund-manager/app/main.py | 262 - apps/refund-manager/app/models.py | 80 - apps/refund-manager/index.html | 520 - apps/refund-manager/requirements.txt | 6 - .../supportconsolek/.env.example | 4 + .../supportconsolek/.gitignore | 10 + .../supportconsolek/.prettierignore | 36 + .../supportconsolek/.prettierrc.json | 12 + .../supportconsolek/supportconsolek/CLAUDE.md | 10 + .../supportconsolek/supportconsolek/README.md | 183 + apps/supportconsolek/supportconsolek/app.yaml | 16 + .../supportconsolek/appkit.plugins.json | 41 + .../supportconsolek/client/components.json | 21 + .../supportconsolek/client/index.html | 18 + .../supportconsolek/client/postcss.config.js | 6 + .../client/public/apple-touch-icon.png | Bin 0 -> 2547 bytes .../client/public/favicon-16x16.png | Bin 0 -> 302 bytes .../client/public/favicon-192x192.png | Bin 0 -> 2762 bytes .../client/public/favicon-32x32.png | Bin 0 -> 492 bytes .../client/public/favicon-48x48.png | Bin 0 -> 686 bytes .../client/public/favicon-512x512.png | Bin 0 -> 10325 bytes .../supportconsolek/client/public/favicon.svg | 6 + .../client/public/site.webmanifest | 19 + .../supportconsolek/client/src/App.tsx | 793 + .../client/src/ErrorBoundary.tsx | 75 + .../supportconsolek/client/src/index.css | 63 + .../supportconsolek/client/src/lib/utils.ts | 6 + .../supportconsolek/client/src/main.tsx | 19 + .../supportconsolek/client/src/vite-env.d.ts | 1 + .../supportconsolek/client/tailwind.config.ts | 10 + .../supportconsolek/client/vite.config.ts | 25 + .../supportconsolek/databricks.yml | 22 + .../supportconsolek/eslint.config.js | 91 + .../supportconsolek/package-lock.json | 13997 ++++++++++++++++ .../supportconsolek/package.json | 84 + .../supportconsolek/playwright.config.ts | 26 + .../supportconsolek/server/lib/lakebase.ts | 123 + .../supportconsolek/server/server.ts | 11 + .../supportconsolek/server/support-plugin.ts | 865 + .../supportconsolek/tests/smoke.spec.ts | 108 + .../supportconsolek/vitest.config.ts | 16 + databricks.yml | 214 +- jobs/complaint_agent_stream.ipynb | 112 - jobs/complaint_generator.ipynb | 141 - jobs/refund_recommender_stream.ipynb | 452 - jobs/support_request_agent_stream.ipynb | 157 + jobs/support_request_generator.ipynb | 118 + .../transformations/transformation.py | 42 +- stages/apps.ipynb | 414 - stages/complaint_agent.ipynb | 732 - stages/complaint_agent_stream.ipynb | 84 - stages/complaint_generator_stream.ipynb | 85 - stages/complaint_lakebase.ipynb | 135 - stages/lakebase.ipynb | 232 - stages/lakeflow.ipynb | 545 +- stages/refunder_agent.ipynb | 950 -- stages/refunder_stream.ipynb | 193 - stages/support_lakebase.ipynb | 201 + stages/support_request_agent.ipynb | 518 + stages/support_request_agent_stream.ipynb | 84 + stages/support_request_generator_stream.ipynb | 85 + 65 files changed, 18264 insertions(+), 4923 deletions(-) delete mode 100644 apps/refund-manager/app.yaml delete mode 100644 apps/refund-manager/app/__init__.py delete mode 100644 apps/refund-manager/app/databricks_events.py delete mode 100644 apps/refund-manager/app/db.py delete mode 100644 apps/refund-manager/app/main.py delete mode 100644 apps/refund-manager/app/models.py delete mode 100644 apps/refund-manager/index.html delete mode 100644 apps/refund-manager/requirements.txt create mode 100644 apps/supportconsolek/supportconsolek/.env.example create mode 100644 apps/supportconsolek/supportconsolek/.gitignore create mode 100644 apps/supportconsolek/supportconsolek/.prettierignore create mode 100644 apps/supportconsolek/supportconsolek/.prettierrc.json create mode 100644 apps/supportconsolek/supportconsolek/CLAUDE.md create mode 100644 apps/supportconsolek/supportconsolek/README.md create mode 100644 apps/supportconsolek/supportconsolek/app.yaml create mode 100644 apps/supportconsolek/supportconsolek/appkit.plugins.json create mode 100644 apps/supportconsolek/supportconsolek/client/components.json create mode 100644 apps/supportconsolek/supportconsolek/client/index.html create mode 100644 apps/supportconsolek/supportconsolek/client/postcss.config.js create mode 100644 apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-192x192.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-32x32.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-48x48.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png create mode 100644 apps/supportconsolek/supportconsolek/client/public/favicon.svg create mode 100644 apps/supportconsolek/supportconsolek/client/public/site.webmanifest create mode 100644 apps/supportconsolek/supportconsolek/client/src/App.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/ErrorBoundary.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/index.css create mode 100644 apps/supportconsolek/supportconsolek/client/src/lib/utils.ts create mode 100644 apps/supportconsolek/supportconsolek/client/src/main.tsx create mode 100644 apps/supportconsolek/supportconsolek/client/src/vite-env.d.ts create mode 100644 apps/supportconsolek/supportconsolek/client/tailwind.config.ts create mode 100644 apps/supportconsolek/supportconsolek/client/vite.config.ts create mode 100644 apps/supportconsolek/supportconsolek/databricks.yml create mode 100644 apps/supportconsolek/supportconsolek/eslint.config.js create mode 100644 apps/supportconsolek/supportconsolek/package-lock.json create mode 100644 apps/supportconsolek/supportconsolek/package.json create mode 100644 apps/supportconsolek/supportconsolek/playwright.config.ts create mode 100644 apps/supportconsolek/supportconsolek/server/lib/lakebase.ts create mode 100644 apps/supportconsolek/supportconsolek/server/server.ts create mode 100644 apps/supportconsolek/supportconsolek/server/support-plugin.ts create mode 100644 apps/supportconsolek/supportconsolek/tests/smoke.spec.ts create mode 100644 apps/supportconsolek/supportconsolek/vitest.config.ts delete mode 100644 jobs/complaint_agent_stream.ipynb delete mode 100644 jobs/complaint_generator.ipynb delete mode 100644 jobs/refund_recommender_stream.ipynb create mode 100644 jobs/support_request_agent_stream.ipynb create mode 100644 jobs/support_request_generator.ipynb delete mode 100644 stages/apps.ipynb delete mode 100644 stages/complaint_agent.ipynb delete mode 100644 stages/complaint_agent_stream.ipynb delete mode 100644 stages/complaint_generator_stream.ipynb delete mode 100644 stages/complaint_lakebase.ipynb delete mode 100644 stages/lakebase.ipynb delete mode 100644 stages/refunder_agent.ipynb delete mode 100644 stages/refunder_stream.ipynb create mode 100644 stages/support_lakebase.ipynb create mode 100644 stages/support_request_agent.ipynb create mode 100644 stages/support_request_agent_stream.ipynb create mode 100644 stages/support_request_generator_stream.ipynb diff --git a/apps/refund-manager/app.yaml b/apps/refund-manager/app.yaml deleted file mode 100644 index b60875e..0000000 --- a/apps/refund-manager/app.yaml +++ /dev/null @@ -1,8 +0,0 @@ -command: - - uvicorn - - app.main:app -env: - - name: DATABRICKS_WAREHOUSE_ID - value: 'e1a2914f603fc607' - - name: DATABRICKS_CATALOG - value: 'cazzper' diff --git a/apps/refund-manager/app/__init__.py b/apps/refund-manager/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/refund-manager/app/databricks_events.py b/apps/refund-manager/app/databricks_events.py deleted file mode 100644 index 05d409c..0000000 --- a/apps/refund-manager/app/databricks_events.py +++ /dev/null @@ -1,76 +0,0 @@ -# app/databricks_events.py -import os, json -from typing import List, Dict, Any -from databricks.sdk import WorkspaceClient -from databricks.sdk.service.sql import ( - ExecuteStatementRequestOnWaitTimeout, - StatementParameterListItem, - Disposition, - Format, -) - -_w = WorkspaceClient() - -WAREHOUSE_ID = os.getenv("DATABRICKS_WAREHOUSE_ID", "") -CATALOG = os.getenv("DATABRICKS_CATALOG", "") -SCHEMA = os.getenv("DATABRICKS_SCHEMA", "lakeflow") - -_COLS = [ - "body", "event_id", "event_type", "location_id", - "order_id", "sequence", "ts" -] - -def _state_name(resp) -> str | None: - st = getattr(resp, "status", None) - state = getattr(st, "state", None) - if state is None: - return None - # Enum-safe → "SUCCEEDED" - name = getattr(state, "name", str(state)) - if "." in name: # e.g. "StatementState.SUCCEEDED" - name = name.split(".")[-1] - return name - -def fetch_order_events(order_id: str) -> List[Dict[str, Any]]: - stmt = f""" - SELECT body, event_id, event_type, location_id, order_id, sequence, ts - FROM {CATALOG}.{SCHEMA}.all_events - WHERE order_id = :oid - AND event_type <> 'driver_ping' - ORDER BY sequence ASC - """.strip() - - params = [StatementParameterListItem(name="oid", value=order_id)] - - resp = _w.statement_execution.execute_statement( - warehouse_id=WAREHOUSE_ID, - catalog=CATALOG, - schema=SCHEMA, - statement=stmt, - parameters=params, - wait_timeout="30s", - on_wait_timeout=ExecuteStatementRequestOnWaitTimeout.CONTINUE, - disposition=Disposition.INLINE, # ensure rows returned inline - format=Format.JSON_ARRAY, # ensure result.data_array - ) - - state = _state_name(resp) - if state and state not in {"SUCCEEDED", "SUCCESS", "COMPLETED"}: - msg = getattr(getattr(resp.status, "error", None), "message", "unknown error") - raise RuntimeError(f"Statement failed: state={state} message={msg}") - - data = resp.result.data_array if resp.result and resp.result.data_array else [] - - out: List[Dict[str, Any]] = [] - for row in data: - d = { _COLS[i]: row[i] for i in range(min(len(_COLS), len(row))) } - v = d.get("body") - if isinstance(v, str): - s = v.strip() - if s.startswith("{") and s.endswith("}"): - try: - d["body"] = json.loads(s) - except Exception: - pass - out.append(d) - return out diff --git a/apps/refund-manager/app/db.py b/apps/refund-manager/app/db.py deleted file mode 100644 index 93b5064..0000000 --- a/apps/refund-manager/app/db.py +++ /dev/null @@ -1,24 +0,0 @@ -# app/db.py -import os -from sqlalchemy import create_engine, event -from databricks.sdk.core import Config -from databricks.sdk import WorkspaceClient - -_cfg = Config() -_w = WorkspaceClient() # caches & refreshes tokens - -PGHOST = os.environ["PGHOST"] -PGPORT = os.environ.get("PGPORT", "5432") -PGDATABASE = os.environ["PGDATABASE"] -PGSSLMODE = os.environ.get("PGSSLMODE", "require") -# default to app client_id unless PGUSER is provided explicitly -PGUSER = os.environ.get("PGUSER", _cfg.client_id) - -DSN = f"postgresql+psycopg://{PGUSER}:@{PGHOST}:{PGPORT}/{PGDATABASE}?sslmode={PGSSLMODE}" - -engine = create_engine(DSN, future=True, pool_pre_ping=True) - -@event.listens_for(engine, "do_connect") -def _provide_token(dialect, conn_rec, cargs, cparams): - # Pass the app OAuth token as the password - cparams["password"] = _w.config.oauth_token().access_token diff --git a/apps/refund-manager/app/main.py b/apps/refund-manager/app/main.py deleted file mode 100644 index 03b8c1b..0000000 --- a/apps/refund-manager/app/main.py +++ /dev/null @@ -1,262 +0,0 @@ -# app/main.py -from pathlib import Path -from typing import Dict, Any, List -import os, json, traceback, logging -from collections import Counter - -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse, JSONResponse -from sqlalchemy import text, bindparam - -from .db import engine -from .models import RefundDecisionCreate, parse_agent_response, ERROR_SUGGESTION -from .databricks_events import fetch_order_events - -DEBUG = os.getenv("DEBUG") in ("1", "true", "TRUE", "yes", "on") -log = logging.getLogger("refund_manager") - -app = FastAPI(title="Refund Manager", version="2.0.0") - -# ─── Configurable schemas ───────────────────────────────────────────────────── -REFUNDS_SCHEMA = os.environ.get("REFUNDS_SCHEMA", "refunds") -RECS_SCHEMA = os.environ.get("RECS_SCHEMA", "recommender") - -def _qi(name: str) -> str: - return '"' + name.replace('"', '""') + '"' - -REFUNDS_TABLE = f"{_qi(REFUNDS_SCHEMA)}.refund_decisions" -RECS_TABLE = f"{_qi(RECS_SCHEMA)}.pg_recommendations" - -# ─── Startup: ensure refunds table ──────────────────────────────────────────── -DDL = f""" -CREATE SCHEMA IF NOT EXISTS {_qi(REFUNDS_SCHEMA)}; - -CREATE TABLE IF NOT EXISTS {REFUNDS_TABLE} ( - id BIGSERIAL PRIMARY KEY, - order_id TEXT NOT NULL, - decided_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), - amount_usd NUMERIC(10,2) NOT NULL CHECK (amount_usd >= 0), - refund_class TEXT NOT NULL CHECK (refund_class IN ('none','partial','full')), - reason TEXT NOT NULL, - decided_by TEXT, - source_suggestion JSONB -); -CREATE INDEX IF NOT EXISTS idx_refund_decisions_order_id ON {REFUNDS_TABLE}(order_id); -""" - -@app.on_event("startup") -def _startup(): - with engine.begin() as conn: - conn.exec_driver_sql(DDL) - -# ─── Static SPA ─────────────────────────────────────────────────────────────── -@app.get("/") -def index(): - path = Path(__file__).parent.parent / "index.html" - if not path.exists(): - raise HTTPException(status_code=404, detail="index.html not found") - return FileResponse(str(path)) - -# ─── Summary (robust to bad JSON) ──────────────────────────────────────────── -@app.get("/api/summary") -def summary(include_zero: bool = False): - suggestions_by_class = Counter() - suggested_total = 0.0 - filtered_count = 0 - - with engine.connect() as conn: - total = conn.exec_driver_sql(f"SELECT COUNT(*) FROM {RECS_TABLE}").scalar_one() - - # Pull raw strings and parse safely - rows = conn.execute(text(f""" - SELECT agent_response - FROM {RECS_TABLE} - WHERE agent_response IS NOT NULL - """)).fetchall() - - for (raw,) in rows: - sug = parse_agent_response(raw) - cls = sug.get("refund_class", "error") - refund_usd = sug.get("refund_usd", 0) - - # Filter out zero-dollar recommendations unless include_zero is True - if not include_zero and refund_usd == 0: - continue - - filtered_count += 1 - suggestions_by_class[cls] += 1 - if cls != "error": - try: - suggested_total += float(refund_usd or 0) - except Exception: - pass - - # Decisions summary (always show all decisions) - decisions_total = conn.exec_driver_sql(f"SELECT COUNT(*) FROM {REFUNDS_TABLE}").scalar_one() - decided_total_usd = conn.exec_driver_sql(f"SELECT COALESCE(SUM(amount_usd),0) FROM {REFUNDS_TABLE}").scalar_one() - dec_by_class_rows = conn.execute(text(f""" - SELECT refund_class, COUNT(*) AS c - FROM {REFUNDS_TABLE} - GROUP BY refund_class - """)).mappings().all() - decisions_by_class = {r["refund_class"]: r["c"] for r in dec_by_class_rows} - - return { - "recommendations_count": filtered_count, - "total_recommendations": total, - "suggestions_by_class": dict(suggestions_by_class), - "suggested_total_usd": round(suggested_total, 2), - "decisions_count": decisions_total, - "decisions_by_class": decisions_by_class, - "decided_total_usd": float(decided_total_usd or 0), - "pending_count": max(filtered_count - decisions_total, 0), - } - -# ─── Recommendations list (robust suggestions) ─────────────────────────────── -@app.get("/api/recommendations") -def list_recommendations(limit: int = 50, offset: int = 0, include_zero: bool = False): - with engine.connect() as conn: - # Fetch a larger batch to account for filtering - # We fetch enough to ensure we can fill the page after filtering - # Using a multiplier to handle the case where many rows are filtered out - fetch_limit = 1000 if not include_zero else limit + offset + 100 - - all_recs = conn.execute( - text(f""" - SELECT order_id, ts, order_ts, agent_response - FROM {RECS_TABLE} - ORDER BY order_ts DESC, order_id DESC - LIMIT :fetch_limit - """), - {"fetch_limit": fetch_limit}, - ).mappings().all() - - # Parse and filter recommendations - filtered_recs = [] - for r in all_recs: - sug = parse_agent_response(r["agent_response"]) - refund_usd = sug.get("refund_usd", 0) - - # Skip zero-dollar recommendations unless include_zero is True - if not include_zero and refund_usd == 0: - continue - - filtered_recs.append({ - "order_id": r["order_id"], - "ts": r["ts"], - "order_ts": r["order_ts"], - "agent_response": r["agent_response"], - "suggestion": sug, - }) - - # Apply pagination to filtered results - total_filtered = len(filtered_recs) - paginated_recs = filtered_recs[offset:offset + limit] - - # Fetch decisions for the paginated results - dec_map: Dict[str, Any] = {} - if paginated_recs: - order_ids = [r["order_id"] for r in paginated_recs] - decs = conn.execute( - text(f""" - SELECT DISTINCT ON (order_id) - order_id, id, decided_ts, amount_usd, refund_class, reason, decided_by - FROM {REFUNDS_TABLE} - WHERE order_id IN :ids - ORDER BY order_id, decided_ts DESC - """).bindparams(bindparam("ids", expanding=True)), - {"ids": tuple(order_ids)}, - ).mappings().all() - dec_map = {d["order_id"]: d for d in decs} - - items: List[Dict[str, Any]] = [] - for r in paginated_recs: - items.append({ - "order_id": r["order_id"], - "ts": r["ts"], - "order_ts": r["order_ts"], - "suggestion": r["suggestion"], # will be ERROR_SUGGESTION for bad rows - "decision": dec_map.get(r["order_id"]) or None, - "status": "applied" if r["order_id"] in dec_map else "pending", - }) - - return { - "items": items, - "limit": limit, - "offset": offset, - "total": total_filtered, - "has_more": offset + limit < total_filtered, - } - -# ─── Apply refund ──────────────────────────────────────────────────────────── -@app.post("/api/refunds") -def apply_refund(body: RefundDecisionCreate): - with engine.begin() as conn: - # latest suggestion (robust parse) - sug_row = conn.execute( - text(f""" - SELECT agent_response - FROM "{RECS_SCHEMA}".pg_recommendations - WHERE order_id = :oid - ORDER BY ts DESC, order_ts DESC - LIMIT 1 - """), - {"oid": body.order_id}, - ).mappings().first() - - from .models import parse_agent_response, ERROR_SUGGESTION # if not already imported at top - source_suggestion = parse_agent_response(sug_row["agent_response"]) if sug_row else dict(ERROR_SUGGESTION) - - row = conn.execute( - text(f""" - INSERT INTO "{REFUNDS_SCHEMA}".refund_decisions - (order_id, amount_usd, refund_class, reason, decided_by, source_suggestion) - VALUES - (:order_id, :amount_usd, :refund_class, :reason, :decided_by, CAST(:source_suggestion AS JSONB)) - RETURNING id, decided_ts - """), - { - "order_id": body.order_id, - "amount_usd": body.amount_usd, - "refund_class": body.refund_class, - "reason": body.reason, - "decided_by": body.decided_by, - "source_suggestion": json.dumps(source_suggestion), - }, - ).mappings().first() - - return JSONResponse({ - "id": row["id"], - "order_id": body.order_id, - "decided_ts": str(row["decided_ts"]), - "amount_usd": body.amount_usd, - "refund_class": body.refund_class, - "reason": body.reason, - "decided_by": body.decided_by, - }, status_code=201) - -# ─── Order events (Databricks SQL) ─────────────────────────────────────────── -@app.get("/api/orders/{order_id}/events") -def order_events(order_id: str, debug: int = 0): - try: - events = fetch_order_events(order_id) - return {"order_id": order_id, "events": events} - except Exception as e: - log.exception("order_events failed for %s", order_id) - if DEBUG or debug: - return JSONResponse( - status_code=500, - content={ - "error": "databricks_statement_failed", - "message": str(e), - "traceback": traceback.format_exc(), - }, - ) - raise HTTPException(status_code=500, detail="Databricks SQL query failed") - -# ─── Health ────────────────────────────────────────────────────────────────── -@app.get("/healthz") -def healthz(): - with engine.connect() as conn: - conn.exec_driver_sql("SELECT 1") - return {"ok": True} diff --git a/apps/refund-manager/app/models.py b/apps/refund-manager/app/models.py deleted file mode 100644 index 3435022..0000000 --- a/apps/refund-manager/app/models.py +++ /dev/null @@ -1,80 +0,0 @@ -# app/models.py -from typing import Any, Dict, Optional -from pydantic import BaseModel, Field, validator -import json -import math - -ERROR_SUGGESTION: Dict[str, Any] = { - "refund_usd": 0.0, - "refund_class": "error", - "reason": "agent did not return valid JSON", -} - -ALLOWED_CLASSES = {"none", "partial", "full"} - -class RefundDecisionCreate(BaseModel): - order_id: str - amount_usd: float = Field(ge=0) - refund_class: str - reason: str - decided_by: Optional[str] = None - - @validator("refund_class") - def _class_ok(cls, v): - if v not in {"none", "partial", "full"}: - raise ValueError("refund_class must be one of {'none','partial','full'}") - return v - -def _coerce_number(x: Any) -> Optional[float]: - try: - f = float(x) - return f if math.isfinite(f) else None - except Exception: - return None - -def parse_agent_response(raw: Optional[str]) -> Dict[str, Any]: - """ - Robustly parse/validate agent_response. - - Accepts only JSON objects with the expected keys. - - If invalid/malformed/missing keys or types => ERROR_SUGGESTION. - - Tries a simple 'trim-to-last-}' recovery for trailing junk. - """ - if not raw or not isinstance(raw, str): - return dict(ERROR_SUGGESTION) - - s = raw.strip() - # 1) try direct - obj = None - try: - obj = json.loads(s) - except Exception: - # 2) quick recovery if there is trailing junk after the last } - if "}" in s: - try: - obj = json.loads(s[: s.rfind("}") + 1]) - except Exception: - obj = None - - if not isinstance(obj, dict): - return dict(ERROR_SUGGESTION) - - # Validate fields - cls = str(obj.get("refund_class", "")).lower() - usd = _coerce_number(obj.get("refund_usd")) - reason = obj.get("reason") - - if cls not in ALLOWED_CLASSES: - # we mark invalid as error - return dict(ERROR_SUGGESTION) - - if usd is None or usd < 0: - # repair to 0 but keep class; however spec says on error use "error" - # since JSON was malformed or types wrong, return error suggestion - return dict(ERROR_SUGGESTION) - - # reason can be missing/empty; it's just a suggestion field, keep if present - if not isinstance(reason, str): - reason = "" - - # Valid suggestion - return {"refund_usd": float(usd), "refund_class": cls, "reason": reason} diff --git a/apps/refund-manager/index.html b/apps/refund-manager/index.html deleted file mode 100644 index 59da43b..0000000 --- a/apps/refund-manager/index.html +++ /dev/null @@ -1,520 +0,0 @@ - - - - - Refund Manager - - - - - - - - - - - -
-
-
-
-
- - - -
-
-

Refund Manager

- -
-
-
-
-
- -
- - -
-
-
Recommendations
-
-
-
-
-
Suggested Total
-
-
USD
-
-
-
Decisions Made
-
-
-
-
-
Pending Review
-
-
Awaiting action
-
-
- - -
-
-
-
-

Orders & Suggestions

-

Review and apply refund recommendations

-
-
- - - - pg_recommendations -
-
- -
- - - -
-
-
- - - - - - - - - - - - -
Order IDSuggested RefundReasonStatusDecisionActions
-
-
-
Latest recommendations shown first
-
- - -
-
-
-
- - - - - - - - - - diff --git a/apps/refund-manager/requirements.txt b/apps/refund-manager/requirements.txt deleted file mode 100644 index 19ef92e..0000000 --- a/apps/refund-manager/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi -uvicorn[standard] -sqlalchemy -psycopg[binary] -databricks-sdk -databricks-sql-connector diff --git a/apps/supportconsolek/supportconsolek/.env.example b/apps/supportconsolek/supportconsolek/.env.example new file mode 100644 index 0000000..bc164a3 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.env.example @@ -0,0 +1,4 @@ +DATABRICKS_HOST=https://... +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=caspers-supportconsole +FLASK_RUN_HOST=0.0.0.0 diff --git a/apps/supportconsolek/supportconsolek/.gitignore b/apps/supportconsolek/supportconsolek/.gitignore new file mode 100644 index 0000000..f2abc32 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +client/dist/ +dist/ +build/ +.env +.databricks/ +.smoke-test/ +test-results/ +playwright-report/ diff --git a/apps/supportconsolek/supportconsolek/.prettierignore b/apps/supportconsolek/supportconsolek/.prettierignore new file mode 100644 index 0000000..7d3d77c --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.prettierignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules + +# Build outputs +dist +build +client/dist +.next +.databricks/ + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Coverage +coverage + +# Cache +.cache +.turbo + +# Lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Vendor +vendor diff --git a/apps/supportconsolek/supportconsolek/.prettierrc.json b/apps/supportconsolek/supportconsolek/.prettierrc.json new file mode 100644 index 0000000..d95a63f --- /dev/null +++ b/apps/supportconsolek/supportconsolek/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "jsxSingleQuote": false +} diff --git a/apps/supportconsolek/supportconsolek/CLAUDE.md b/apps/supportconsolek/supportconsolek/CLAUDE.md new file mode 100644 index 0000000..69d5b13 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/CLAUDE.md @@ -0,0 +1,10 @@ +# AI Assistant Instructions + + +## Databricks AppKit + +This project uses Databricks AppKit packages. For AI assistant guidance on using these packages, refer to: + +- **@databricks/appkit** (Backend SDK): [./node_modules/@databricks/appkit/CLAUDE.md](./node_modules/@databricks/appkit/CLAUDE.md) +- **@databricks/appkit-ui** (UI Integration, Charts, Tables, SSE, and more.): [./node_modules/@databricks/appkit-ui/CLAUDE.md](./node_modules/@databricks/appkit-ui/CLAUDE.md) + diff --git a/apps/supportconsolek/supportconsolek/README.md b/apps/supportconsolek/supportconsolek/README.md new file mode 100644 index 0000000..a39fb95 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/README.md @@ -0,0 +1,183 @@ +# Minimal Databricks App + +A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, tRPC, and Tailwind CSS. + +## Prerequisites + +- Node.js 18+ and npm +- Databricks CLI (for deployment) +- Access to a Databricks workspace + +## Databricks Authentication + +### Local Development + +For local development, configure your environment variables by creating a `.env` file: + +```bash +cp env.example .env +``` + +Edit `.env` and set the following: + +```env +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com +DATABRICKS_WAREHOUSE_ID=your-warehouse-id +DATABRICKS_APP_PORT=8000 +``` + +### CLI Authentication + +The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: + +#### OAuth U2M + +Interactive browser-based authentication with short-lived tokens: + +```bash +databricks auth login --host https://your-workspace.cloud.databricks.com +``` + +This will open your browser to complete authentication. The CLI saves credentials to `~/.databrickscfg`. + +#### Configuration Profiles + +Use multiple profiles for different workspaces: + +```ini +[DEFAULT] +host = https://dev-workspace.cloud.databricks.com + +[production] +host = https://prod-workspace.cloud.databricks.com +client_id = prod-client-id +client_secret = prod-client-secret +``` + +Deploy using a specific profile: + +```bash +databricks bundle deploy -t prod --profile production +``` + +**Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. + +## Getting Started + +### Install Dependencies + +```bash +npm install +``` + +### Development + +Run the app in development mode with hot reload: + +```bash +npm run dev +``` + +The app will be available at the URL shown in the console output. + +### Build + +Build both client and server for production: + +```bash +npm run build +``` + +This creates: + +- `dist/server/` - Compiled server code +- `client/dist/` - Bundled client assets + +### Production + +Run the production build: + +```bash +npm start +``` + +## Code Quality + +```bash +# Type checking +npm run typecheck + +# Linting +npm run lint +npm run lint:fix + +# Formatting +npm run format +npm run format:fix +``` + +## Deployment with Databricks Asset Bundles + +### 1. Configure Bundle + +Update `databricks.yml` with your workspace settings: + +```yaml +targets: + dev: + workspace: + host: https://your-workspace.cloud.databricks.com + variables: + warehouse_id: your-warehouse-id +``` + +### 2. Validate Bundle + +```bash +databricks bundle validate +``` + +### 3. Deploy + +Deploy to the development target: + +```bash +databricks bundle deploy -t dev +``` + +### 4. Run + +Start the deployed app: + +```bash +databricks bundle run -t dev +``` + +### Deploy to Production + +1. Configure the production target in `databricks.yml` +2. Deploy to production: + +```bash +databricks bundle deploy -t prod +``` + +## Project Structure + +``` +* client/ # React frontend + * src/ # Source code + * public/ # Static assets +* server/ # Express backend + * server.ts # Server entry point + * trpc.ts # tRPC router +* shared/ # Shared types +* databricks.yml # Bundle configuration +``` + +## Tech Stack + +- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS +- **Backend**: Node.js, Express, tRPC +- **UI Components**: Radix UI, shadcn/ui +- **Databricks**: App Kit SDK, Analytics SDK diff --git a/apps/supportconsolek/supportconsolek/app.yaml b/apps/supportconsolek/supportconsolek/app.yaml new file mode 100644 index 0000000..ae53b81 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/app.yaml @@ -0,0 +1,16 @@ +command: ['npm', 'run', 'start'] +env: + - name: PGHOST + value: "ep-icy-voice-d1skfonp.database.us-west-2.cloud.databricks.com" + - name: PGPORT + value: "5432" + - name: PGDATABASE + value: "databricks_postgres" + - name: PGSSLMODE + value: "require" + - name: LAKEBASE_ENDPOINT + value: "projects/casperskitchens-support-db/branches/production/endpoints/ep-icy-voice-d1skfonp" + - name: DATABRICKS_WAREHOUSE_ID + value: "e5ed18828056f3cf" + - name: SUPPORT_AGENT_ENDPOINT_NAME + value: "caspers_support_agent" \ No newline at end of file diff --git a/apps/supportconsolek/supportconsolek/appkit.plugins.json b/apps/supportconsolek/supportconsolek/appkit.plugins.json new file mode 100644 index 0000000..67f3874 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/appkit.plugins.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/apps/supportconsolek/supportconsolek/client/components.json b/apps/supportconsolek/supportconsolek/client/components.json new file mode 100644 index 0000000..13e1db0 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/supportconsolek/supportconsolek/client/index.html b/apps/supportconsolek/supportconsolek/client/index.html new file mode 100644 index 0000000..26f3416 --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + caspers-supportconsole + + +
+ + + diff --git a/apps/supportconsolek/supportconsolek/client/postcss.config.js b/apps/supportconsolek/supportconsolek/client/postcss.config.js new file mode 100644 index 0000000..51a6e4e --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png b/apps/supportconsolek/supportconsolek/client/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32053bd2d17ca6d4cdcfa2e2390cdc4db61658c1 GIT binary patch literal 2547 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 literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png b/apps/supportconsolek/supportconsolek/client/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c16eb471f63a06f45119cebeb085dcc6ce3252 GIT binary patch literal 302 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 Uz+N|&sQ>@~07*qoM6N<$f;65h(*OVf literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png b/apps/supportconsolek/supportconsolek/client/public/favicon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..14d7d209db562940b4e78d23fce203d69afe7c38 GIT binary patch literal 10325 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 boicPbM8)qSin-XseaGC)+VuG$w_E=Y^dlWB literal 0 HcmV?d00001 diff --git a/apps/supportconsolek/supportconsolek/client/public/favicon.svg b/apps/supportconsolek/supportconsolek/client/public/favicon.svg new file mode 100644 index 0000000..cb30c1e --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/supportconsolek/supportconsolek/client/public/site.webmanifest b/apps/supportconsolek/supportconsolek/client/public/site.webmanifest new file mode 100644 index 0000000..03106ce --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "{{.project_name}}", + "short_name": "{{.project_name}}", + "icons": [ + { + "src": "/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/supportconsolek/supportconsolek/client/src/App.tsx b/apps/supportconsolek/supportconsolek/client/src/App.tsx new file mode 100644 index 0000000..b0b85ea --- /dev/null +++ b/apps/supportconsolek/supportconsolek/client/src/App.tsx @@ -0,0 +1,793 @@ +/** + * ⚠️ BEFORE MODIFYING THIS FILE: + * + * 1. Create SQL files in config/queries/ + * 2. Run `npm run typegen` to generate query types + * 3. Check appKitTypes.d.ts for available types + * + * Common Mistakes: + * - DataTable does NOT accept `data` or `columns` props + * - Charts use `xKey` and `yKey`, NOT `seriesKey`/`nameKey`/`valueKey` + * - useAnalyticsQuery has no `enabled` option - use conditional rendering + */ +import { useEffect, useRef, useState } from "react"; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + Textarea, +} from "@databricks/appkit-ui/react"; + +type Summary = { requests: number; actions: number; replies: number }; + +type Recommendation = { + amount_usd?: number | null; + reason?: string; +}; + +type Report = { + draft_response?: string; + past_interactions_summary?: string; + order_details_summary?: string; + decision_confidence?: string; + escalation_flag?: boolean; + refund_recommendation?: Recommendation | null; + credit_recommendation?: Recommendation | null; +}; + +type CaseState = { + case_status: "pending" | "in_progress" | "done" | "blocked"; + next_action: string; + has_reply: boolean; + has_refund: boolean; + has_credit: boolean; + action_count: number; + reply_count: number; + regen_count: number; + last_action_type?: string | null; + last_event_at?: string | null; + latest_report_source?: string | null; +}; + +type TimelineEvent = { + event_type: string; + event_at: string; + actor?: string | null; + details?: Record; +}; + +type RegenerationItem = { + regenerated_report_id: number; + operator_context?: string | null; + actor?: string | null; + created_at: string; + report: Report; +}; + +type RequestItem = { + support_request_id: string; + user_id: string; + user_display_name?: string | null; + order_id: string; + ts: string; + report: Report; + case_state?: CaseState; +}; + +type RequestDetails = RequestItem & { + actions: Array>; + replies: Array>; + regenerations?: RegenerationItem[]; + timeline?: TimelineEvent[]; +}; + +type NoticeState = { + kind: "success" | "error"; + message: string; + supportRequestId?: string; +} | null; + +function App() { + const PAGE_SIZE = 50; + const [summary, setSummary] = useState(null); + const [requests, setRequests] = useState([]); + const [totalRequests, setTotalRequests] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selected, setSelected] = useState(null); + const [replyText, setReplyText] = useState(""); + const [operatorContext, setOperatorContext] = useState(""); + const [actor, setActor] = useState(""); + const [refundAmount, setRefundAmount] = useState(""); + const [creditAmount, setCreditAmount] = useState(""); + const [loading, setLoading] = useState(false); + const [detailsLoading, setDetailsLoading] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<"apply_refund" | "apply_credit" | "send_reply" | "regenerate" | null>(null); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const drawerScrollRef = useRef(null); + + useEffect(() => { + void refresh(); + }, [currentPage]); + + const formatCurrency = (value?: number | null) => + typeof value === "number" ? `$${value.toFixed(2)}` : "No recommendation"; + + const formatTs = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Recent"; + } + return date.toLocaleString(); + }; + + const statusLabel = (status: CaseState["case_status"] | undefined) => { + if (status === "done") return "Done"; + if (status === "in_progress") return "In Progress"; + if (status === "blocked") return "Blocked"; + return "Pending"; + }; + + const statusVariant = (status: CaseState["case_status"] | undefined): "default" | "secondary" | "outline" => { + if (status === "done") return "default"; + if (status === "in_progress") return "secondary"; + return "outline"; + }; + + const nextActionLabel = (nextAction: string | undefined) => { + const mapping: Record = { + review_report: "Review report", + apply_resolution_or_regenerate: "Apply resolution or re-gen", + send_customer_reply: "Send customer reply", + monitor: "Monitor case", + investigate_blocker: "Investigate blocker", + continue_investigation: "Continue investigation", + }; + return mapping[nextAction ?? ""] ?? "Review report"; + }; + + const suggestedRefund = selected?.report?.refund_recommendation?.amount_usd ?? null; + const suggestedCredit = selected?.report?.credit_recommendation?.amount_usd ?? null; + const selectedCaseState = selected?.case_state; + + const appliedRefund = selected?.actions.find((a) => a.action_type === "apply_refund"); + const appliedCredit = selected?.actions.find((a) => a.action_type === "apply_credit"); + + const toNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; + }; + + const parseErrorMessage = async (res: Response): Promise => { + try { + const body = (await res.json()) as { message?: string; error?: string }; + return body.message || body.error || `Request failed (${res.status})`; + } catch { + return `Request failed (${res.status})`; + } + }; + + const refresh = async () => { + setLoading(true); + setError(null); + try { + const offset = (currentPage - 1) * PAGE_SIZE; + const [summaryRes, reqRes] = await Promise.all([ + fetch("/api/support/summary"), + fetch(`/api/support/requests?limit=${PAGE_SIZE}&offset=${offset}`), + ]); + if (!summaryRes.ok || !reqRes.ok) { + throw new Error("Failed to load support data"); + } + const summaryJson = (await summaryRes.json()) as Summary; + const reqJson = (await reqRes.json()) as { items?: RequestItem[]; total?: number }; + setSummary(summaryJson); + setRequests(reqJson.items || []); + setTotalRequests(reqJson.total ?? 0); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + const openDetails = async ( + supportRequestId: string, + options?: { showLoading?: boolean; preserveScroll?: boolean }, + ) => { + const showLoading = options?.showLoading ?? true; + const preserveScroll = options?.preserveScroll ?? false; + const previousScrollTop = preserveScroll ? drawerScrollRef.current?.scrollTop ?? 0 : 0; + setIsDrawerOpen(true); + if (showLoading) { + setDetailsLoading(true); + } + setNotice((prev) => (prev?.supportRequestId === supportRequestId ? prev : null)); + try { + const res = await fetch(`/api/support/requests/${supportRequestId}`); + if (!res.ok) { + throw new Error("Failed to load request details"); + } + const json = (await res.json()) as RequestDetails; + setSelected(json); + setReplyText(json?.report?.draft_response ?? ""); + setRefundAmount( + typeof json?.report?.refund_recommendation?.amount_usd === "number" + ? String(json.report.refund_recommendation.amount_usd) + : "", + ); + setCreditAmount( + typeof json?.report?.credit_recommendation?.amount_usd === "number" + ? String(json.report.credit_recommendation.amount_usd) + : "", + ); + setError(null); + if (preserveScroll) { + requestAnimationFrame(() => { + if (drawerScrollRef.current) { + drawerScrollRef.current.scrollTop = previousScrollTop; + } + }); + } + return true; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + return false; + } finally { + if (showLoading) { + setDetailsLoading(false); + } + } + }; + + const totalPages = Math.max(1, Math.ceil(totalRequests / PAGE_SIZE)); + + const applyAction = async (actionType: "apply_refund" | "apply_credit", amount: string) => { + if (!selected) return; + if (!amount || Number.isNaN(Number(amount))) return; + setPendingAction(actionType); + setNotice(null); + try { + const res = await fetch("/api/support/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + action_type: actionType, + amount_usd: Number(amount), + actor: actor || null, + payload: { source: "appkit-ui" }, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + setNotice({ + kind: "success", + message: actionType === "apply_credit" ? "Credits applied successfully." : "Refund applied successfully.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + setNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + const sendReply = async () => { + if (!selected) return; + setPendingAction("send_reply"); + setNotice(null); + try { + const res = await fetch("/api/support/replies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + message_text: replyText, + sent_by: actor || null, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + setNotice({ + kind: "success", + message: "Reply sent successfully.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + setNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + const regenerateReport = async () => { + if (!selected) return; + setPendingAction("regenerate"); + setNotice(null); + try { + const res = await fetch("/api/support/regenerate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + support_request_id: selected.support_request_id, + order_id: selected.order_id, + user_id: selected.user_id, + actor: actor || null, + operator_context: operatorContext || null, + current_report: selected.report, + }), + }); + if (!res.ok) { + throw new Error(await parseErrorMessage(res)); + } + setNotice({ + kind: "success", + message: "Report regenerated with operator context.", + supportRequestId: selected.support_request_id, + }); + await openDetails(selected.support_request_id, { showLoading: false, preserveScroll: true }); + await refresh(); + } catch (e) { + setNotice({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setPendingAction(null); + } + }; + + return ( +
+
+
+

Support Console

+

+ Triage support requests, review agent analysis, and take operator actions. +

+
+ +
+ + {error && ( + + Error: {error} + + )} + +
+ + + Requests + + +
{summary?.requests ?? "-"}
+
+
+ + + Actions + + +
{summary?.actions ?? "-"}
+
+
+ + + Replies + + +
{summary?.replies ?? "-"}
+
+
+
+ +
+ + + Support Requests + + + {loading && requests.length === 0 && ( + <> + {Array.from({ length: 4 }).map((_, idx) => ( +
+
+
+
+
+ ))} + + )} + {requests.length === 0 && ( +
+ No support requests yet. +
+ )} + {requests.map((r) => { + const previewSource = + r.report?.draft_response || + r.report?.order_details_summary || + "Support request ready for review."; + const preview = previewSource.replace(/\s+/g, " ").trim(); + return ( +
+
+
+
+ {r.user_display_name ?? "Customer"} +
+ + {statusLabel(r.case_state?.case_status)} + + + Next: {nextActionLabel(r.case_state?.next_action)} + + {r.case_state?.has_reply && Replied} + {r.case_state?.has_refund && Refund Applied} + {r.case_state?.has_credit && Credits Applied} + + Refund: {formatCurrency(r.report?.refund_recommendation?.amount_usd)} + + + Credit: {formatCurrency(r.report?.credit_recommendation?.amount_usd)} + +
+
+ Updated {formatTs(r.case_state?.last_event_at ?? r.ts)} + + Replies {r.case_state?.reply_count ?? 0} + + + Actions {r.case_state?.action_count ?? 0} + + + Re-gens {r.case_state?.regen_count ?? 0} + +
+
{preview}
+
+ +
+ ); + })} +
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+ + +
+ + +
+ + Request Details + + Review agent analysis and take operator actions for the selected support case. + + +
+ {notice && ( + + + {notice.message} + + + )} + {detailsLoading &&
Loading request details...
} + {!detailsLoading && !selected && ( +
Select a request from the list.
+ )} + {!detailsLoading && selected && ( + <> +
+
+ {selected.user_display_name ?? "Customer"} + + Updated: {formatTs(selectedCaseState?.last_event_at ?? selected.ts)} + + + Confidence: {selected.report?.decision_confidence ?? "unknown"} + + + {statusLabel(selectedCaseState?.case_status)} + +
+
+
+
Case State
+
+ Next: {nextActionLabel(selectedCaseState?.next_action)} + Last Action: {selectedCaseState?.last_action_type ?? "none"} + Report: {selectedCaseState?.latest_report_source ?? "sync"} + Replies: {selectedCaseState?.reply_count ?? 0} + Actions: {selectedCaseState?.action_count ?? 0} + Re-gens: {selectedCaseState?.regen_count ?? 0} +
+
+
+
Timeline
+
+ {(selected.timeline ?? []).slice(0, 8).map((event, idx) => ( +
+
{event.event_type.replaceAll("_", " ")}
+
+ {formatTs(event.event_at)}{event.actor ? ` · ${event.actor}` : ""} +
+
+ ))} + {(selected.timeline ?? []).length === 0 && ( +
No activity yet.
+ )} +
+
+
+
Past Interactions Summary
+
+ {selected.report?.past_interactions_summary} +
+
+
+
Order Details Summary
+
+ {selected.report?.order_details_summary} +
+
+
+ + + Refund Recommendation + + + {formatCurrency(selected.report?.refund_recommendation?.amount_usd)} + + + + + Credit Recommendation + + + {formatCurrency(selected.report?.credit_recommendation?.amount_usd)} + + +
+
+
+ {appliedRefund ? ( + + Refund Applied: {formatCurrency(toNumber(appliedRefund.amount_usd))} + + ) : ( + Refund not applied + )} + {appliedCredit ? ( + + Credits Applied: {formatCurrency(toNumber(appliedCredit.amount_usd))} + + ) : ( + Credits not applied + )} +
+
+
+
Replies
+
+ {selected.replies.length === 0 && ( +
No replies sent yet.
+ )} + {selected.replies.map((reply, idx) => ( +
+
+ {formatTs(String(reply.created_at ?? ""))} + {reply.sent_by ? ` · ${String(reply.sent_by)}` : ""} +
+
+ {String(reply.message_text ?? "")} +
+
+ ))} +
+
+
+
Regeneration History
+
+ {(selected.regenerations ?? []).length === 0 && ( +
No regenerated reports yet.
+ )} + {(selected.regenerations ?? []).map((regen) => ( +
+
+ {formatTs(regen.created_at)}{regen.actor ? ` · ${regen.actor}` : ""} +
+
+ {regen.operator_context || "No operator context provided."} +
+
+ ))} +
+
+
+
Reply Draft
+