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:
2026-04-25 12:35:08 +00:00
parent 4bc054f315
commit 5e9963f51b
29 changed files with 5152 additions and 59 deletions
+84
View File
@@ -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>
);
}