5e9963f51b
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>
243 lines
8.7 KiB
TypeScript
243 lines
8.7 KiB
TypeScript
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>
|
||
);
|
||
}
|