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:
+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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user