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
+242
View File
@@ -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>
);
}