Add: Web UI (React 19 + Vite + shadcn/ui) + FastAPI REST layer
Adds a self-contained web interface that wraps the MCP tools so end users
don't need to issue MCP commands. Single Docker container serves:
/ → React SPA (RTL Hebrew, Heebo font, shadcn/ui components)
/api/* → FastAPI REST endpoints used by the SPA
/api/docs → OpenAPI / Swagger
/mcp → existing FastMCP streamable-http endpoint
/health → liveness probe
REST endpoints (in nadlan_mcp/web_app.py):
GET /api/search/address?q=... — autocomplete + best-effort gush/חלקה
via Govmap PARCEL_ALL layer
POST /api/search/deals — Govmap deals around an address
POST /api/search/appraisals — Decisive-appraiser search (gov.il)
GET /api/appraisals/pdf?url=.. — Stream PDFs through this server,
carrying the upstream x-client-id
header (a normal browser fetch fails)
UI flow:
- Address search → resolves gush/חלקה → shows BOTH deals (Govmap) AND
appraiser decisions (gov.il) for the same parcel side-by-side in tabs.
- Block+plot search → goes straight to appraisals.
- Appraiser-name search → all decisions by that appraiser.
Stack chosen for RTL-first usability and longevity: React 19, Vite 6,
TypeScript, Tailwind, shadcn/ui (Radix primitives), TanStack Query,
Lucide icons. ~295KB gzipped JS, no SSR overhead.
Multi-stage Dockerfile: Node 22 builds web/ in stage 1, Python 3.13
runtime serves the dist + the FastAPI app in stage 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,14 @@ requirements-dev.txt
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Web build outputs (rebuilt fresh in Docker stage 1)
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/.vite/
|
||||||
|
web/.tsbuildinfo
|
||||||
|
web/.tsbuildinfo-node
|
||||||
|
web/dist-node/
|
||||||
|
|
||||||
# Docker files (avoid recursive copies)
|
# Docker files (avoid recursive copies)
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
|
|||||||
+26
-22
@@ -1,45 +1,49 @@
|
|||||||
# Dockerfile for Nadlan-MCP HTTP Server
|
# ─── Stage 1: build the React UI ────────────────────────────────────────
|
||||||
# Supports deployment to Render, Railway, Docker, and other container platforms
|
FROM node:22-alpine AS web-build
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Cache deps separately from sources.
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: Python runtime ────────────────────────────────────────────
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies if needed
|
# Build deps for curl_cffi (needs libcurl-impersonate). curl_cffi vendors
|
||||||
RUN apt-get update && \
|
# its own libs but a C toolchain is still useful for any pure-Python whls
|
||||||
apt-get install -y --no-install-recommends \
|
# without prebuilt wheels for slim.
|
||||||
gcc \
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gcc \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy package files for better layer caching
|
# Install Python deps from pyproject.
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml README.md ./
|
||||||
COPY README.md .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# Copy application code
|
# App code.
|
||||||
COPY nadlan_mcp/ ./nadlan_mcp/
|
COPY nadlan_mcp/ ./nadlan_mcp/
|
||||||
COPY run_http_server.py .
|
COPY run_http_server.py ./
|
||||||
|
|
||||||
# Create non-root user for security
|
# React build artifacts → web/dist (where web_app.py looks for them).
|
||||||
RUN useradd -m -u 1000 appuser && \
|
COPY --from=web-build /web/dist ./web/dist
|
||||||
chown -R appuser:appuser /app
|
|
||||||
|
# Non-root user.
|
||||||
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose port (will be overridden by PORT env var in production)
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Health check
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8000}/health')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT:-8000}/health')" || exit 1
|
||||||
|
|
||||||
# Run the HTTP server
|
|
||||||
CMD ["python", "run_http_server.py"]
|
CMD ["python", "run_http_server.py"]
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""
|
||||||
|
Web application: FastAPI REST endpoints + FastMCP mount + React static.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
/ → React SPA (index.html + assets)
|
||||||
|
/api/... → FastAPI REST endpoints used by the React UI
|
||||||
|
/api/docs → Swagger UI for the REST API
|
||||||
|
/mcp → FastMCP streamable-http endpoint (for LLM clients)
|
||||||
|
/health → liveness probe (also mounted under /api/health)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from starlette.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
from nadlan_mcp.fastmcp_server import (
|
||||||
|
client as govmap_client,
|
||||||
|
)
|
||||||
|
from nadlan_mcp.fastmcp_server import (
|
||||||
|
decisive_appraiser_client,
|
||||||
|
mcp,
|
||||||
|
)
|
||||||
|
from nadlan_mcp.govil.client import ALLOWED_PDF_HOSTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Request / response schemas
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DealsSearchRequest(BaseModel):
|
||||||
|
address: str = Field(..., min_length=2, max_length=200)
|
||||||
|
years_back: int = Field(2, ge=1, le=10)
|
||||||
|
radius_meters: int = Field(100, ge=10, le=2000)
|
||||||
|
max_deals: int = Field(50, ge=1, le=200)
|
||||||
|
deal_type: int = Field(2, ge=1, le=2)
|
||||||
|
|
||||||
|
|
||||||
|
class AppraisalsSearchRequest(BaseModel):
|
||||||
|
block: str | None = Field(None, max_length=20)
|
||||||
|
plot: str | None = Field(None, max_length=20)
|
||||||
|
decisive_appraiser: str | None = Field(None, max_length=100)
|
||||||
|
committee: str | None = Field(None, max_length=100)
|
||||||
|
decision_date_from: str | None = Field(None, max_length=20)
|
||||||
|
decision_date_to: str | None = Field(None, max_length=20)
|
||||||
|
publicity_date_from: str | None = Field(None, max_length=20)
|
||||||
|
publicity_date_to: str | None = Field(None, max_length=20)
|
||||||
|
search_text: str | None = Field(None, max_length=200)
|
||||||
|
appraisal_header: str | None = Field(None, max_length=200)
|
||||||
|
max_results: int = Field(30, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# App factory
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""
|
||||||
|
Build the combined FastAPI + FastMCP application.
|
||||||
|
|
||||||
|
The static-file mount at "/" must be the LAST mount so that more
|
||||||
|
specific paths (/api, /mcp, /health) take precedence.
|
||||||
|
"""
|
||||||
|
# FastMCP needs its lifespan to run, so we forward it to FastAPI.
|
||||||
|
if hasattr(mcp, "streamable_http_app"):
|
||||||
|
mcp_app = mcp.streamable_http_app()
|
||||||
|
elif hasattr(mcp, "http_app"):
|
||||||
|
mcp_app = mcp.http_app()
|
||||||
|
else:
|
||||||
|
raise RuntimeError("FastMCP version does not expose an HTTP app")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Nadlan-MCP Web",
|
||||||
|
description=(
|
||||||
|
"REST API for Israeli real-estate (Govmap) and decisive-appraiser "
|
||||||
|
"(Ministry of Justice) data. Powers the React UI; also exposes the "
|
||||||
|
"raw FastMCP endpoint at /mcp for LLM clients."
|
||||||
|
),
|
||||||
|
version="2.1.0",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=mcp_app.router.lifespan_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS — only relevant during local dev when Vite serves on :5173.
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Health ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/health")
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "service": "nadlan-mcp"}
|
||||||
|
|
||||||
|
# ── /api/search/address ───────────────────────────────────────────
|
||||||
|
@app.get("/api/search/address")
|
||||||
|
async def search_address(q: str = Query(..., min_length=2, max_length=120)):
|
||||||
|
"""Autocomplete an Israeli address. Also returns coordinates and (when
|
||||||
|
available) the gush/חלקה for the top match."""
|
||||||
|
try:
|
||||||
|
response = govmap_client.autocomplete_address(q)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("autocomplete_address failed")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in response.results:
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"text": r.text,
|
||||||
|
"type": r.type,
|
||||||
|
"score": r.score,
|
||||||
|
"id": r.id,
|
||||||
|
}
|
||||||
|
if r.coordinates:
|
||||||
|
item["coordinates"] = {
|
||||||
|
"longitude": r.coordinates.longitude,
|
||||||
|
"latitude": r.coordinates.latitude,
|
||||||
|
}
|
||||||
|
results.append(item)
|
||||||
|
|
||||||
|
# Best-effort gush/חלקה for the top result. Use PARCEL_ALL layer
|
||||||
|
# explicitly — the default layer 16 in get_gush_helka is `nadlan`
|
||||||
|
# (deals) which has null gush/parcel for many addresses.
|
||||||
|
gush_helka = None
|
||||||
|
if response.results and response.results[0].coordinates:
|
||||||
|
try:
|
||||||
|
coords = response.results[0].coordinates
|
||||||
|
gh = _query_parcel_layer(govmap_client, coords.longitude, coords.latitude)
|
||||||
|
gush_helka = _extract_gush_helka(gh)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"parcel lookup failed for '{q}': {e}")
|
||||||
|
|
||||||
|
return {"query": q, "results": results, "gush_helka": gush_helka}
|
||||||
|
|
||||||
|
# ── /api/search/deals ─────────────────────────────────────────────
|
||||||
|
@app.post("/api/search/deals")
|
||||||
|
async def search_deals(req: DealsSearchRequest):
|
||||||
|
"""Find recent real-estate deals around an address (Govmap)."""
|
||||||
|
try:
|
||||||
|
deals = govmap_client.find_recent_deals_for_address(
|
||||||
|
req.address,
|
||||||
|
req.years_back,
|
||||||
|
req.radius_meters,
|
||||||
|
req.max_deals,
|
||||||
|
req.deal_type,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("find_recent_deals_for_address failed")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
# Search-center coords for the map (best-effort).
|
||||||
|
search_coords = None
|
||||||
|
try:
|
||||||
|
ar = govmap_client.autocomplete_address(req.address)
|
||||||
|
if ar.results and ar.results[0].coordinates:
|
||||||
|
c = ar.results[0].coordinates
|
||||||
|
search_coords = {"longitude": c.longitude, "latitude": c.latitude}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Strip bloat to keep payload small for the UI.
|
||||||
|
bloat = {
|
||||||
|
"shape",
|
||||||
|
"sourceorder",
|
||||||
|
"objectid",
|
||||||
|
"priority",
|
||||||
|
"source_polygon_id",
|
||||||
|
"settlementId",
|
||||||
|
"streetCode",
|
||||||
|
"dealId",
|
||||||
|
"polygonId",
|
||||||
|
}
|
||||||
|
deal_dicts = []
|
||||||
|
for idx, deal in enumerate(deals, start=1):
|
||||||
|
d = deal.model_dump(mode="json", exclude_none=True)
|
||||||
|
d = {k: v for k, v in d.items() if k not in bloat}
|
||||||
|
d["id"] = idx
|
||||||
|
d["deal_source"] = getattr(deal, "deal_source", None)
|
||||||
|
deal_dicts.append(d)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"search": {
|
||||||
|
"address": req.address,
|
||||||
|
"coordinates": search_coords,
|
||||||
|
"years_back": req.years_back,
|
||||||
|
"radius_meters": req.radius_meters,
|
||||||
|
"deal_type": req.deal_type,
|
||||||
|
},
|
||||||
|
"total": len(deal_dicts),
|
||||||
|
"deals": deal_dicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── /api/search/appraisals ────────────────────────────────────────
|
||||||
|
@app.post("/api/search/appraisals")
|
||||||
|
async def search_appraisals(req: AppraisalsSearchRequest):
|
||||||
|
"""Search decisive-appraiser decisions (gov.il / Ministry of Justice)."""
|
||||||
|
try:
|
||||||
|
response = decisive_appraiser_client.search_decisions_paged(
|
||||||
|
max_results=req.max_results,
|
||||||
|
block=req.block,
|
||||||
|
plot=req.plot,
|
||||||
|
decisive_appraiser=req.decisive_appraiser,
|
||||||
|
committee=req.committee,
|
||||||
|
decision_date_from=req.decision_date_from,
|
||||||
|
decision_date_to=req.decision_date_to,
|
||||||
|
publicity_date_from=req.publicity_date_from,
|
||||||
|
publicity_date_to=req.publicity_date_to,
|
||||||
|
search_text=req.search_text,
|
||||||
|
appraisal_header=req.appraisal_header,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("search_decisions_paged failed")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
decisions = []
|
||||||
|
for idx, decision in enumerate(response.results, start=1):
|
||||||
|
d = decision.model_dump(mode="json", exclude_none=True)
|
||||||
|
documents = d.pop("documents", []) or []
|
||||||
|
d["id"] = idx
|
||||||
|
d["pdf_url"] = documents[0]["file_url"] if documents else None
|
||||||
|
d["all_documents"] = documents
|
||||||
|
decisions.append(d)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_in_db": response.total_results,
|
||||||
|
"returned": len(decisions),
|
||||||
|
"filters": req.model_dump(exclude_none=True),
|
||||||
|
"decisions": decisions,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── /api/appraisals/pdf ───────────────────────────────────────────
|
||||||
|
@app.get("/api/appraisals/pdf")
|
||||||
|
async def proxy_appraisal_pdf(url: str = Query(..., min_length=20)):
|
||||||
|
"""
|
||||||
|
Stream a decisive-appraiser PDF through this server.
|
||||||
|
|
||||||
|
Required because the upstream PDF host demands the same x-client-id
|
||||||
|
header as the search API; a normal browser fetch would fail.
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https" or parsed.hostname not in ALLOWED_PDF_HOSTS:
|
||||||
|
raise HTTPException(status_code=400, detail="URL host not allowed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reuse the curl_cffi session that already carries x-client-id.
|
||||||
|
response = decisive_appraiser_client._session.get(
|
||||||
|
url,
|
||||||
|
timeout=(
|
||||||
|
decisive_appraiser_client.config.connect_timeout,
|
||||||
|
decisive_appraiser_client.config.read_timeout * 4,
|
||||||
|
),
|
||||||
|
headers={"Accept": "application/pdf,*/*"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("PDF proxy upstream call failed")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=f"Upstream returned {response.status_code}",
|
||||||
|
)
|
||||||
|
if not response.content.startswith(b"%PDF"):
|
||||||
|
raise HTTPException(status_code=502, detail="Upstream did not return a PDF")
|
||||||
|
|
||||||
|
# Suggest a reasonable filename for the browser.
|
||||||
|
filename = parsed.path.rstrip("/").split("/")[-1] or "appraisal"
|
||||||
|
if not filename.lower().endswith(".pdf"):
|
||||||
|
filename = f"{filename}.pdf"
|
||||||
|
|
||||||
|
def _iter() -> Any:
|
||||||
|
yield response.content
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_iter(),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'inline; filename="{filename}"',
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── /mcp/ → FastMCP ───────────────────────────────────────────────
|
||||||
|
app.mount("/mcp", mcp_app)
|
||||||
|
|
||||||
|
# ── / → React static (must be LAST) ───────────────────────────────
|
||||||
|
web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
|
||||||
|
if web_dist.is_dir():
|
||||||
|
app.mount(
|
||||||
|
"/assets",
|
||||||
|
StaticFiles(directory=web_dist / "assets"),
|
||||||
|
name="assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}", include_in_schema=False)
|
||||||
|
async def spa_fallback(full_path: str):
|
||||||
|
# Unknown paths fall back to index.html so React Router can take over.
|
||||||
|
index = web_dist / "index.html"
|
||||||
|
if index.is_file():
|
||||||
|
return FileResponse(index)
|
||||||
|
return JSONResponse({"detail": "UI not built"}, status_code=404)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Web build directory not found at {web_dist}; UI will not be served. "
|
||||||
|
f"Run `cd web && pnpm build` to populate it."
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def no_ui():
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"service": "nadlan-mcp",
|
||||||
|
"ui": "not built",
|
||||||
|
"api_docs": "/api/docs",
|
||||||
|
"mcp": "/mcp",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _query_parcel_layer(client: Any, longitude: float, latitude: float) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Query Govmap's PARCEL_ALL layer at a point. Returns the raw JSON.
|
||||||
|
|
||||||
|
The default `client.get_gush_helka` queries layer 16 (`nadlan`), which has
|
||||||
|
null gush/parcel for many address points. PARCEL_ALL is the cadastral layer
|
||||||
|
that reliably exposes them.
|
||||||
|
"""
|
||||||
|
url = f"{client.base_url}/layers-catalog/entitiesByPoint"
|
||||||
|
payload = {
|
||||||
|
"point": [longitude, latitude],
|
||||||
|
"layers": [{"layerId": "PARCEL_ALL"}],
|
||||||
|
"tolerance": 5,
|
||||||
|
}
|
||||||
|
timeout = (client.config.connect_timeout, client.config.read_timeout)
|
||||||
|
response = client.session.post(url, json=payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gush_helka(govmap_response: Any) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Pull the (gush, helka) pair out of the layered Govmap response.
|
||||||
|
|
||||||
|
Real shape (layerId 16 / nadlan layer):
|
||||||
|
{"data": [{"name": "...", "entities": [
|
||||||
|
{"fields": [{"fieldName": "גוש", "fieldValue": "6212"},
|
||||||
|
{"fieldName": "חלקה", "fieldValue": "894"}, ...]}
|
||||||
|
]}]}
|
||||||
|
Values can be null when the layer has no data at that point — we skip
|
||||||
|
those and keep looking through every entity in every layer.
|
||||||
|
"""
|
||||||
|
if not isinstance(govmap_response, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
block: str | None = None
|
||||||
|
plot: str | None = None
|
||||||
|
|
||||||
|
for layer in govmap_response.get("data") or []:
|
||||||
|
if not isinstance(layer, dict):
|
||||||
|
continue
|
||||||
|
for entity in layer.get("entities") or []:
|
||||||
|
if not isinstance(entity, dict):
|
||||||
|
continue
|
||||||
|
for f in entity.get("fields") or []:
|
||||||
|
name = (f.get("fieldName") or "").strip()
|
||||||
|
value = f.get("fieldValue")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
value = str(value).strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
name in ("גוש", "מספר גוש", "gush", "block", "GUSH_NUM", "gush_num")
|
||||||
|
and not block
|
||||||
|
):
|
||||||
|
block = value
|
||||||
|
elif (
|
||||||
|
name in ("חלקה", "מספר חלקה", "helka", "parcel", "PARCEL", "PARCEL_NUM")
|
||||||
|
and not plot
|
||||||
|
):
|
||||||
|
plot = value
|
||||||
|
if block and plot:
|
||||||
|
return {"block": block, "plot": plot}
|
||||||
|
|
||||||
|
if block or plot:
|
||||||
|
return {"block": block, "plot": plot}
|
||||||
|
return None
|
||||||
@@ -28,6 +28,8 @@ dependencies = [
|
|||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
"fastmcp>=2.13.0,<3.0.0",
|
"fastmcp>=2.13.0,<3.0.0",
|
||||||
"curl_cffi>=0.5.0",
|
"curl_cffi>=0.5.0",
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+17
-37
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
HTTP Server Entry Point for Nadlan-MCP
|
HTTP Server Entry Point for Nadlan-MCP.
|
||||||
|
|
||||||
This module provides an HTTP/SSE transport server for the Nadlan-MCP service,
|
Serves three things on a single port:
|
||||||
enabling deployment to cloud platforms like Render, Railway, or Docker containers.
|
/ → React SPA (search & browse Israeli real estate data)
|
||||||
|
/api/... → REST endpoints used by the SPA (also documented at /api/docs)
|
||||||
|
/mcp → FastMCP streamable-http endpoint (for LLM clients)
|
||||||
|
|
||||||
For local Claude Desktop integration, use run_fastmcp_server.py (stdio transport) instead.
|
For local Claude Desktop integration, use run_fastmcp_server.py (stdio).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,52 +16,30 @@ import sys
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from nadlan_mcp.fastmcp_server import mcp
|
from nadlan_mcp.web_app import create_app
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Module-level app for ASGI servers (uvicorn web_app:app, gunicorn, etc.).
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run the FastMCP server with HTTP transport using uvicorn."""
|
def main() -> None:
|
||||||
# Get port from environment variable (Render sets PORT automatically)
|
|
||||||
port = int(os.environ.get("PORT", 8000))
|
port = int(os.environ.get("PORT", 8000))
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("Starting Nadlan-MCP HTTP Server")
|
logger.info("Starting Nadlan-MCP HTTP Server")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("Transport: HTTP (via uvicorn)")
|
logger.info(f" UI: http://{host}:{port}/")
|
||||||
logger.info(f"Host: {host}")
|
logger.info(f" API docs: http://{host}:{port}/api/docs")
|
||||||
logger.info(f"Port: {port}")
|
logger.info(f" MCP: http://{host}:{port}/mcp")
|
||||||
logger.info(f"MCP Endpoint: http://{host}:{port}/mcp")
|
logger.info(f" Health: http://{host}:{port}/health")
|
||||||
logger.info(f"Health Check: http://{host}:{port}/health")
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the HTTP ASGI app from FastMCP
|
|
||||||
# Try different possible method names based on FastMCP version
|
|
||||||
if hasattr(mcp, "streamable_http_app"):
|
|
||||||
app = mcp.streamable_http_app()
|
|
||||||
elif hasattr(mcp, "http_app"):
|
|
||||||
app = mcp.http_app()
|
|
||||||
elif hasattr(mcp, "get_app"):
|
|
||||||
app = mcp.get_app()
|
|
||||||
else:
|
|
||||||
# Fallback: try to access the app directly
|
|
||||||
app = getattr(mcp, "app", None)
|
|
||||||
if app is None:
|
|
||||||
raise AttributeError(
|
|
||||||
"FastMCP instance has no HTTP app method. "
|
|
||||||
"Available methods: " + ", ".join(dir(mcp))
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Using app: {type(app).__name__}")
|
|
||||||
|
|
||||||
# Run with uvicorn
|
|
||||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start HTTP server: {e}", exc_info=True)
|
logger.error(f"Failed to start HTTP server: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-node
|
||||||
|
.vite
|
||||||
|
.tsbuildinfo
|
||||||
|
.tsbuildinfo-node
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="he" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>נדל"ן ושמאי מכריע — Marcus-Law</title>
|
||||||
|
<meta name="description" content="חיפוש עסקאות נדל"ן והחלטות שמאי מכריע באזור ספציפי" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3514
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "nadlan-mcp-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --max-warnings=0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Building2, Gavel, MapPin } from "lucide-react";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SearchBar, SearchQuery } from "@/components/SearchBar";
|
||||||
|
import { DealsTable } from "@/components/DealsTable";
|
||||||
|
import { AppraisalsTable } from "@/components/AppraisalsTable";
|
||||||
|
|
||||||
|
function useAddressInfo(address: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["address", address],
|
||||||
|
queryFn: () => api.searchAddress(address!),
|
||||||
|
enabled: !!address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDeals(query: SearchQuery | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["deals", query],
|
||||||
|
queryFn: () =>
|
||||||
|
api.searchDeals({
|
||||||
|
address: query!.address!,
|
||||||
|
years_back: 3,
|
||||||
|
radius_meters: 100,
|
||||||
|
max_deals: 50,
|
||||||
|
}),
|
||||||
|
enabled: !!query && query.mode === "address" && !!query.address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAppraisals(query: SearchQuery | null, blockOverride?: string, plotOverride?: string) {
|
||||||
|
// For "address" mode, we feed in block/plot resolved by the address lookup.
|
||||||
|
const block = query?.block || blockOverride;
|
||||||
|
const plot = query?.plot || plotOverride;
|
||||||
|
const appraiser = query?.appraiser;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["appraisals", { block, plot, appraiser }],
|
||||||
|
queryFn: () =>
|
||||||
|
api.searchAppraisals({
|
||||||
|
block,
|
||||||
|
plot,
|
||||||
|
decisive_appraiser: appraiser,
|
||||||
|
max_results: 30,
|
||||||
|
}),
|
||||||
|
enabled: Boolean(block || appraiser),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [query, setQuery] = useState<SearchQuery | null>(null);
|
||||||
|
|
||||||
|
const addressInfo = useAddressInfo(
|
||||||
|
query?.mode === "address" ? query.address : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedBlock = addressInfo.data?.gush_helka?.block ?? undefined;
|
||||||
|
const resolvedPlot = addressInfo.data?.gush_helka?.plot ?? undefined;
|
||||||
|
|
||||||
|
const deals = useDeals(query);
|
||||||
|
const appraisals = useAppraisals(query, resolvedBlock, resolvedPlot);
|
||||||
|
|
||||||
|
const isLoadingTopBar =
|
||||||
|
addressInfo.isFetching || deals.isFetching || appraisals.isFetching;
|
||||||
|
|
||||||
|
// Default tab: appraiser-only search → appraisals; otherwise → deals.
|
||||||
|
const defaultTab = useMemo(
|
||||||
|
() => (query?.mode === "appraiser" ? "appraisals" : "deals"),
|
||||||
|
[query?.mode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white pb-20">
|
||||||
|
<header className="border-b bg-white">
|
||||||
|
<div className="container py-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Building2 className="text-primary" />
|
||||||
|
נדל"ן ושמאי מכריע
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
חיפוש עסקאות נדל"ן והחלטות שמאי מכריע — Marcus-Law
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground hidden sm:block">
|
||||||
|
מקורות:{" "}
|
||||||
|
<span className="font-medium">Govmap</span> ·{" "}
|
||||||
|
<span className="font-medium">משרד המשפטים</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mt-8 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">חיפוש</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
חפש לפי כתובת מלאה (להצגת עסקאות נדל"ן + החלטות שמאי באותו גוש),
|
||||||
|
לפי גוש וחלקה ספציפיים, או לפי שם של שמאי מכריע.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SearchBar onSearch={setQuery} isLoading={isLoadingTopBar} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!query && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
הזן כתובת כדי להתחיל. עסקאות מתקבלות מ-Govmap, החלטות שמאי מ-gov.il.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query && (
|
||||||
|
<>
|
||||||
|
{/* Address resolution summary */}
|
||||||
|
{query.mode === "address" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<MapPin className="size-4 text-primary" />
|
||||||
|
{query.address}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{addressInfo.isFetching && "מאתר את הכתובת..."}
|
||||||
|
{addressInfo.data?.gush_helka && (
|
||||||
|
<>
|
||||||
|
זוהה: גוש{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{addressInfo.data.gush_helka.block || "?"}
|
||||||
|
</span>
|
||||||
|
{addressInfo.data.gush_helka.plot && (
|
||||||
|
<>
|
||||||
|
{" "}חלקה{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{addressInfo.data.gush_helka.plot}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{addressInfo.data && !addressInfo.data.gush_helka && (
|
||||||
|
<span className="text-amber-700">
|
||||||
|
לא נמצאו פרטי גוש/חלקה לכתובת זו — חיפוש שמאי לפי כתובת לא יבוצע.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue={defaultTab} className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="deals" disabled={query.mode === "appraiser"}>
|
||||||
|
<Building2 className="ms-1 size-4" />
|
||||||
|
עסקאות נדל"ן
|
||||||
|
{deals.data && (
|
||||||
|
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
|
||||||
|
{deals.data.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="appraisals">
|
||||||
|
<Gavel className="ms-1 size-4" />
|
||||||
|
שמאי מכריע
|
||||||
|
{appraisals.data && (
|
||||||
|
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
|
||||||
|
{appraisals.data.returned}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="deals" className="mt-6">
|
||||||
|
{deals.isFetching && <TableSkeleton />}
|
||||||
|
{deals.error && (
|
||||||
|
<ErrorBox error={String(deals.error)} />
|
||||||
|
)}
|
||||||
|
{deals.data && !deals.isFetching && (
|
||||||
|
<DealsTable deals={deals.data.deals} />
|
||||||
|
)}
|
||||||
|
{!deals.isFetching && !deals.error && !deals.data && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
חיפוש עסקאות זמין רק לפי כתובת.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appraisals" className="mt-6">
|
||||||
|
{appraisals.isFetching && <TableSkeleton />}
|
||||||
|
{appraisals.error && (
|
||||||
|
<ErrorBox error={String(appraisals.error)} />
|
||||||
|
)}
|
||||||
|
{appraisals.data && !appraisals.isFetching && (
|
||||||
|
<AppraisalsTable
|
||||||
|
decisions={appraisals.data.decisions}
|
||||||
|
totalInDb={appraisals.data.total_in_db}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!appraisals.isFetching && !appraisals.error && !appraisals.data && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
{query.mode === "address"
|
||||||
|
? "ממתין לזיהוי גוש/חלקה..."
|
||||||
|
: "אין נתונים."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBox({ error }: { error: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||||
|
שגיאה: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
AddressSearchResponse,
|
||||||
|
AppraisalsSearchRequest,
|
||||||
|
AppraisalsSearchResponse,
|
||||||
|
DealsSearchRequest,
|
||||||
|
DealsSearchResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = res.statusText;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
detail = body?.detail || detail;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, detail);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
searchAddress(q: string): Promise<AddressSearchResponse> {
|
||||||
|
return request(`/search/address?q=${encodeURIComponent(q)}`);
|
||||||
|
},
|
||||||
|
searchDeals(req: DealsSearchRequest): Promise<DealsSearchResponse> {
|
||||||
|
return request(`/search/deals`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchAppraisals(req: AppraisalsSearchRequest): Promise<AppraisalsSearchResponse> {
|
||||||
|
return request(`/search/appraisals`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pdfProxyUrl(url: string): string {
|
||||||
|
return `${API_BASE}/appraisals/pdf?url=${encodeURIComponent(url)}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Mirrors the Pydantic schemas in nadlan_mcp/web_app.py.
|
||||||
|
// Keep them in sync if you edit the backend.
|
||||||
|
|
||||||
|
export interface Coordinates {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressResult {
|
||||||
|
text: string;
|
||||||
|
type: string;
|
||||||
|
score: number;
|
||||||
|
id: string;
|
||||||
|
coordinates?: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressSearchResponse {
|
||||||
|
query: string;
|
||||||
|
results: AddressResult[];
|
||||||
|
gush_helka: { block: string | null; plot: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealsSearchRequest {
|
||||||
|
address: string;
|
||||||
|
years_back?: number;
|
||||||
|
radius_meters?: number;
|
||||||
|
max_deals?: number;
|
||||||
|
deal_type?: 1 | 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deal {
|
||||||
|
id: number;
|
||||||
|
deal_amount?: number;
|
||||||
|
asset_area?: number;
|
||||||
|
rooms?: number;
|
||||||
|
floor?: number;
|
||||||
|
deal_date?: string;
|
||||||
|
property_type_description?: string;
|
||||||
|
settlementNameHeb?: string;
|
||||||
|
streetNameHeb?: string;
|
||||||
|
house_num?: string | number;
|
||||||
|
price_per_sqm?: number;
|
||||||
|
deal_source?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealsSearchResponse {
|
||||||
|
search: {
|
||||||
|
address: string;
|
||||||
|
coordinates: Coordinates | null;
|
||||||
|
years_back: number;
|
||||||
|
radius_meters: number;
|
||||||
|
deal_type: number;
|
||||||
|
};
|
||||||
|
total: number;
|
||||||
|
deals: Deal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppraisalsSearchRequest {
|
||||||
|
block?: string;
|
||||||
|
plot?: string;
|
||||||
|
decisive_appraiser?: string;
|
||||||
|
committee?: string;
|
||||||
|
decision_date_from?: string;
|
||||||
|
decision_date_to?: string;
|
||||||
|
publicity_date_from?: string;
|
||||||
|
publicity_date_to?: string;
|
||||||
|
search_text?: string;
|
||||||
|
appraisal_header?: string;
|
||||||
|
max_results?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppraisalDocument {
|
||||||
|
file_url: string;
|
||||||
|
display_name?: string;
|
||||||
|
extension?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppraisalDecision {
|
||||||
|
id: number;
|
||||||
|
appraisal_header?: string;
|
||||||
|
appraisal_type?: string;
|
||||||
|
appraisal_version?: string;
|
||||||
|
decisive_appraiser?: string;
|
||||||
|
appraiser_type?: string;
|
||||||
|
block?: string;
|
||||||
|
plot?: string;
|
||||||
|
committee?: string;
|
||||||
|
decision_date?: string;
|
||||||
|
publicity_date?: string;
|
||||||
|
pdf_url: string | null;
|
||||||
|
all_documents: AppraisalDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppraisalsSearchResponse {
|
||||||
|
total_in_db: number;
|
||||||
|
returned: number;
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
decisions: AppraisalDecision[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
import { AppraisalDecision } from "@/api/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
decisions: AppraisalDecision[];
|
||||||
|
totalInDb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppraisalsTable({ decisions, totalInDb }: Props) {
|
||||||
|
if (!decisions.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
לא נמצאו החלטות שמאי מכריע באזור הזה
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
נמצאו {decisions.length} מתוך {totalInDb.toLocaleString("he-IL")} החלטות במאגר
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-start font-medium">תאריך הכרעה</th>
|
||||||
|
<th className="p-3 text-start font-medium">שמאי מכריע</th>
|
||||||
|
<th className="p-3 text-start font-medium">ועדה</th>
|
||||||
|
<th className="p-3 text-start font-medium">גוש/חלקה</th>
|
||||||
|
<th className="p-3 text-start font-medium">סוג שומה</th>
|
||||||
|
<th className="p-3 text-start font-medium">כותרת ההחלטה</th>
|
||||||
|
<th className="p-3 text-end font-medium">PDF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{decisions.map((d) => (
|
||||||
|
<tr key={d.id} className="border-t hover:bg-accent/40 align-top">
|
||||||
|
<td className="p-3 whitespace-nowrap">{formatDate(d.decision_date)}</td>
|
||||||
|
<td className="p-3 whitespace-nowrap">{d.decisive_appraiser || "—"}</td>
|
||||||
|
<td className="p-3 whitespace-nowrap">{d.committee || "—"}</td>
|
||||||
|
<td className="p-3 whitespace-nowrap tabular-nums">
|
||||||
|
{d.block ?? "—"}
|
||||||
|
{d.plot ? ` / ${d.plot}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{d.appraisal_type || "—"}</td>
|
||||||
|
<td className="p-3 max-w-md">
|
||||||
|
<span className="line-clamp-2">{d.appraisal_header || "—"}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-end whitespace-nowrap">
|
||||||
|
{d.pdf_url ? (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
title="פתח PDF בלשונית חדשה"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={api.pdfProxyUrl(d.pdf_url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FileText />
|
||||||
|
פתח
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
asChild
|
||||||
|
title="הורד PDF"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={api.pdfProxyUrl(d.pdf_url) + "&download=1"}
|
||||||
|
download={`${d.block || "appraisal"}-${d.plot || "0"}-${d.id}.pdf`}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Deal } from "@/api/types";
|
||||||
|
import { formatCurrencyILS, formatDate, formatNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deals: Deal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
same_building: "אותו בניין",
|
||||||
|
street: "באותו רחוב",
|
||||||
|
neighborhood: "בשכונה",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DealsTable({ deals }: Props) {
|
||||||
|
if (!deals.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
לא נמצאו עסקאות באזור הזה
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3 text-start font-medium">תאריך</th>
|
||||||
|
<th className="p-3 text-start font-medium">כתובת</th>
|
||||||
|
<th className="p-3 text-start font-medium">סוג</th>
|
||||||
|
<th className="p-3 text-end font-medium">חדרים</th>
|
||||||
|
<th className="p-3 text-end font-medium">קומה</th>
|
||||||
|
<th className="p-3 text-end font-medium">שטח (מ"ר)</th>
|
||||||
|
<th className="p-3 text-end font-medium">מחיר</th>
|
||||||
|
<th className="p-3 text-end font-medium">מחיר/מ"ר</th>
|
||||||
|
<th className="p-3 text-start font-medium">קרבה</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deals.map((d) => {
|
||||||
|
const street = (d.streetNameHeb as string) || "";
|
||||||
|
const houseNum = (d.house_num as string | number | undefined) ?? "";
|
||||||
|
const settlement = (d.settlementNameHeb as string) || "";
|
||||||
|
const address = [street, houseNum, settlement].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<tr key={d.id} className="border-t hover:bg-accent/40">
|
||||||
|
<td className="p-3 whitespace-nowrap">{formatDate(d.deal_date)}</td>
|
||||||
|
<td className="p-3">{address || "—"}</td>
|
||||||
|
<td className="p-3">{d.property_type_description || "—"}</td>
|
||||||
|
<td className="p-3 text-end tabular-nums">{d.rooms ?? "—"}</td>
|
||||||
|
<td className="p-3 text-end tabular-nums">{d.floor ?? "—"}</td>
|
||||||
|
<td className="p-3 text-end tabular-nums">{formatNumber(d.asset_area)}</td>
|
||||||
|
<td className="p-3 text-end tabular-nums">
|
||||||
|
{formatCurrencyILS(d.deal_amount)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-end tabular-nums">
|
||||||
|
{formatCurrencyILS(d.price_per_sqm)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{d.deal_source ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-block rounded-full px-2 py-0.5 text-xs " +
|
||||||
|
(d.deal_source === "same_building"
|
||||||
|
? "bg-emerald-100 text-emerald-800"
|
||||||
|
: d.deal_source === "street"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-slate-100 text-slate-700")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{SOURCE_LABELS[d.deal_source] || d.deal_source}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export type SearchMode = "address" | "block_plot" | "appraiser";
|
||||||
|
|
||||||
|
export interface SearchQuery {
|
||||||
|
mode: SearchMode;
|
||||||
|
address?: string;
|
||||||
|
block?: string;
|
||||||
|
plot?: string;
|
||||||
|
appraiser?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSearch: (q: SearchQuery) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onSearch, isLoading }: Props) {
|
||||||
|
const [mode, setMode] = useState<SearchMode>("address");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [block, setBlock] = useState("");
|
||||||
|
const [plot, setPlot] = useState("");
|
||||||
|
const [appraiser, setAppraiser] = useState("");
|
||||||
|
|
||||||
|
const submit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mode === "address" && address.trim()) {
|
||||||
|
onSearch({ mode, address: address.trim() });
|
||||||
|
} else if (mode === "block_plot" && block.trim()) {
|
||||||
|
onSearch({ mode, block: block.trim(), plot: plot.trim() || undefined });
|
||||||
|
} else if (mode === "appraiser" && appraiser.trim()) {
|
||||||
|
onSearch({ mode, appraiser: appraiser.trim() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["address", "כתובת"],
|
||||||
|
["block_plot", "גוש וחלקה"],
|
||||||
|
["appraiser", "שמאי מכריע"],
|
||||||
|
] as const
|
||||||
|
).map(([m, label]) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={
|
||||||
|
"rounded-full border px-4 py-1.5 transition-colors " +
|
||||||
|
(mode === m
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background text-muted-foreground hover:bg-accent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "address" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="address">כתובת מלאה</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="לדוגמה: סוקולוב 38 חולון"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "block_plot" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="block">גוש (חובה)</Label>
|
||||||
|
<Input
|
||||||
|
id="block"
|
||||||
|
value={block}
|
||||||
|
onChange={(e) => setBlock(e.target.value)}
|
||||||
|
placeholder="6212"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="plot">חלקה (אופציונלי)</Label>
|
||||||
|
<Input
|
||||||
|
id="plot"
|
||||||
|
value={plot}
|
||||||
|
onChange={(e) => setPlot(e.target.value)}
|
||||||
|
placeholder="894"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "appraiser" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="appraiser">שם השמאי המכריע</Label>
|
||||||
|
<Input
|
||||||
|
id="appraiser"
|
||||||
|
value={appraiser}
|
||||||
|
onChange={(e) => setAppraiser(e.target.value)}
|
||||||
|
placeholder="לדוגמה: כהן אליהו"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
|
<Search className="ms-1" />
|
||||||
|
{isLoading ? "מחפש..." : "חפש"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 215 70% 35%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 215 70% 35%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-family: "Heebo", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
/* Tailwind's default scrollbar gutters cause RTL drift; force stable. */
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1rem",
|
||||||
|
screens: { "2xl": "1400px" },
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Heebo", "system-ui", "-apple-system", "sans-serif"],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"tsBuildInfoFile": "./.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"outDir": "./dist-node",
|
||||||
|
"tsBuildInfoFile": "./.tsbuildinfo-node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// During dev (`pnpm dev`), Vite serves on :5173 and proxies /api + /mcp + /health
|
||||||
|
// to the FastAPI server on :8765 (or PORT env var).
|
||||||
|
const BACKEND_PORT = process.env.BACKEND_PORT || "8765";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": `http://127.0.0.1:${BACKEND_PORT}`,
|
||||||
|
"/mcp": `http://127.0.0.1:${BACKEND_PORT}`,
|
||||||
|
"/health": `http://127.0.0.1:${BACKEND_PORT}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user