Files
nadlan-mcp/web/src/App.tsx
T
chaim 5e9963f51b 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>
2026-04-25 12:35:08 +00:00

243 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}