From a40e68254b106f2d0e0126fa4d6e6bbae6791ea9 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Tue, 5 May 2026 19:55:34 +0300 Subject: [PATCH] feat: weighted match scoring, photo upload, property detail page - scoring.ts: pure scoreMatch + scoreMatchBreakdown with per-criterion weights - matching.ts: soft scoring (0-100), updates score on re-sync, threshold 20 - search-form-sheet: weight selectors (1-5) per criterion - customer-search-actions: save/update weight fields - storage-actions: upload/delete property images to property-images bucket - storage-utils: getPropertyImagePreviewUrl, parseImageIds helpers - property-image-uploader: client component with preview grid + delete - property-form-sheet: integrated image uploader - properties/[id]: detail page with gallery, specs, matches sidebar - properties-client: Detay link in dropdown - matches page: MatchesClient with click-to-breakdown dialog - sunum page: cover image from first imageIds entry - matches-client + match-breakdown-dialog: score breakdown per criterion --- src/app/(dashboard)/activities/page.tsx | 2 +- .../(dashboard)/customers/matches/page.tsx | 76 ++--- .../(dashboard)/customers/searches/page.tsx | 2 +- src/app/(dashboard)/investors/page.tsx | 2 +- src/app/(dashboard)/presentations/page.tsx | 2 +- src/app/(dashboard)/properties/[id]/page.tsx | 262 ++++++++++++++++++ src/app/sunum/[token]/page.tsx | 16 +- .../customers/search-form-sheet.tsx | 131 ++++++--- .../matches/match-breakdown-dialog.tsx | 129 +++++++++ src/components/matches/matches-client.tsx | 121 ++++++++ .../properties/properties-client.tsx | 9 +- .../properties/property-form-sheet.tsx | 10 + .../properties/property-image-uploader.tsx | 103 +++++++ src/lib/appwrite/customer-queries.ts | 4 +- src/lib/appwrite/customer-search-actions.ts | 10 + src/lib/appwrite/matching.ts | 102 ++----- src/lib/appwrite/property-queries.ts | 4 +- src/lib/appwrite/schema.ts | 7 + src/lib/appwrite/storage-actions.ts | 53 ++++ src/lib/appwrite/storage-utils.ts | 21 ++ src/lib/scoring.ts | 201 ++++++++++++++ src/lib/validation/customer-searches.ts | 7 + 22 files changed, 1105 insertions(+), 169 deletions(-) create mode 100644 src/app/(dashboard)/properties/[id]/page.tsx create mode 100644 src/components/matches/match-breakdown-dialog.tsx create mode 100644 src/components/matches/matches-client.tsx create mode 100644 src/components/properties/property-image-uploader.tsx create mode 100644 src/lib/appwrite/storage-actions.ts create mode 100644 src/lib/appwrite/storage-utils.ts create mode 100644 src/lib/scoring.ts diff --git a/src/app/(dashboard)/activities/page.tsx b/src/app/(dashboard)/activities/page.tsx index 04f06ef..d5d4afc 100644 --- a/src/app/(dashboard)/activities/page.tsx +++ b/src/app/(dashboard)/activities/page.tsx @@ -27,7 +27,7 @@ export default async function ActivitiesPage() { }), ]); - const activities = activitiesResult.rows as unknown as Activity[]; + const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[]; return (
diff --git a/src/app/(dashboard)/customers/matches/page.tsx b/src/app/(dashboard)/customers/matches/page.tsx index f43d955..4dc250f 100644 --- a/src/app/(dashboard)/customers/matches/page.tsx +++ b/src/app/(dashboard)/customers/matches/page.tsx @@ -5,14 +5,20 @@ import { Query } from "node-appwrite"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { listCustomers } from "@/lib/appwrite/customer-queries"; import { listProperties } from "@/lib/appwrite/property-queries"; -import { DATABASE_ID, TABLES, type PropertyMatch } from "@/lib/appwrite/schema"; +import { + DATABASE_ID, + TABLES, + type PropertyMatch, + type CustomerSearch, +} from "@/lib/appwrite/schema"; import { createAdminClient } from "@/lib/appwrite/server"; +import { MatchesClient } from "@/components/matches/matches-client"; export default async function MatchesPage() { const ctx = await requireTenant(); const { tablesDB } = createAdminClient(); - const [customers, properties, matchesResult] = await Promise.all([ + const [customers, properties, matchesResult, searchesResult] = await Promise.all([ listCustomers(ctx.tenantId), listProperties(ctx.tenantId), tablesDB.listRows({ @@ -24,58 +30,30 @@ export default async function MatchesPage() { Query.limit(500), ], }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.customerSearches, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.limit(200), + ], + }), ]); - const matches = matchesResult.rows as unknown as PropertyMatch[]; - const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name])); - const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p.title])); + const matches = ( + JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[] + ).sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + + const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[]; return (
-
-

Eşleşmeler

- {matches.length} eşleşme -
- -
- - - - - - - - - - - {matches.length === 0 && ( - - - - )} - {matches.map((m) => ( - - - - - - - ))} - -
MüşteriİlanTarihGörüntülendi
- Henüz eşleşme yok. -
{customerMap[m.customerId] ?? m.customerId}{propertyMap[m.propertyId] ?? m.propertyId} - {new Date(m.$createdAt).toLocaleDateString("tr-TR")} - - {m.viewedAt ? ( - - {new Date(m.viewedAt).toLocaleDateString("tr-TR")} - - ) : ( - Hayır - )} -
-
+
); } diff --git a/src/app/(dashboard)/customers/searches/page.tsx b/src/app/(dashboard)/customers/searches/page.tsx index 8da4e33..e20a152 100644 --- a/src/app/(dashboard)/customers/searches/page.tsx +++ b/src/app/(dashboard)/customers/searches/page.tsx @@ -25,7 +25,7 @@ export default async function SearchesPage() { }), ]); - const searches = searchesResult.rows as unknown as CustomerSearch[]; + const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[]; return (
diff --git a/src/app/(dashboard)/investors/page.tsx b/src/app/(dashboard)/investors/page.tsx index 778b2e5..8cfda4c 100644 --- a/src/app/(dashboard)/investors/page.tsx +++ b/src/app/(dashboard)/investors/page.tsx @@ -21,7 +21,7 @@ export default async function InvestorsPage() { ], }); - const investors = result.rows as unknown as Investor[]; + const investors = JSON.parse(JSON.stringify(result.rows)) as Investor[]; return (
diff --git a/src/app/(dashboard)/presentations/page.tsx b/src/app/(dashboard)/presentations/page.tsx index 5c07b96..a7f70a3 100644 --- a/src/app/(dashboard)/presentations/page.tsx +++ b/src/app/(dashboard)/presentations/page.tsx @@ -27,7 +27,7 @@ export default async function PresentationsPage() { }), ]); - const presentations = presResult.rows as unknown as Presentation[]; + const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[]; return (
diff --git a/src/app/(dashboard)/properties/[id]/page.tsx b/src/app/(dashboard)/properties/[id]/page.tsx new file mode 100644 index 0000000..7dd5431 --- /dev/null +++ b/src/app/(dashboard)/properties/[id]/page.tsx @@ -0,0 +1,262 @@ +export const dynamic = "force-dynamic"; + +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { + DATABASE_ID, + TABLES, + PROPERTY_TYPE_LABELS, + LISTING_TYPE_LABELS, + PROPERTY_STATUS_LABELS, + ACTIVITY_TYPE_LABELS, + type Property, + type PropertyMatch, + type Activity, +} from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils"; +import { Badge } from "@/components/ui/badge"; + +interface Props { + params: Promise<{ id: string }>; +} + +export default async function PropertyDetailPage({ params }: Props) { + const { id } = await params; + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + let property: Property; + try { + const row = await tablesDB.getRow(DATABASE_ID, TABLES.properties, id); + property = JSON.parse(JSON.stringify(row)) as Property; + } catch { + notFound(); + } + + if (property.tenantId !== ctx.tenantId) notFound(); + + const [customers, matchesResult, activitiesResult] = await Promise.all([ + listCustomers(ctx.tenantId), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.propertyMatches, + queries: [ + Query.equal("propertyId", id), + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("score"), + Query.limit(50), + ], + }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.activities, + queries: [ + Query.equal("propertyId", id), + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(20), + ], + }), + ]); + + const matches = JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[]; + const activities = JSON.parse(JSON.stringify(activitiesResult.rows)) as Activity[]; + const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name])); + const imageIds = parseImageIds(property.imageIds); + + const statusColor: Record = { + aktif: "bg-green-100 text-green-700", + pasif: "bg-gray-100 text-gray-600", + satildi: "bg-orange-100 text-orange-700", + kiralandit: "bg-blue-100 text-blue-700", + }; + + return ( +
+ {/* Header */} +
+ + + İlanlar + +
+ +
+
+

{property.title}

+

+ {[property.neighborhood, property.district, property.city].filter(Boolean).join(", ")} +

+
+ + {PROPERTY_STATUS_LABELS[property.status] ?? property.status} + +
+ + {/* Photo gallery */} + {imageIds.length > 0 && ( +
+
+ {imageIds.map((fileId, i) => ( +
2 ? "col-span-2 row-span-2" : ""}`} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`${property.title} 2 ? "480px" : "240px" }} + /> +
+ ))} +
+
+ )} + +
+ {/* Price */} +
+
+
+ + {property.price.toLocaleString("tr-TR")} + + {property.currency ?? "TRY"} +
+ +
+ + + {property.roomCount && } + {property.netM2 && } + {property.grossM2 && } + {property.floor != null && } + {property.totalFloors != null && ( + + )} + {property.buildingAge != null && ( + + )} +
+ + {property.address && ( +
+ Adres: + {property.address} +
+ )} +
+ + {property.description && ( +
+

Açıklama

+

+ {property.description} +

+
+ )} + + {/* Activities */} + {activities.length > 0 && ( +
+

Aktiviteler

+
+ {activities.map((a) => ( +
+ + {ACTIVITY_TYPE_LABELS[a.type] ?? a.type} + +
+

{a.title}

+ {a.description && ( +

{a.description}

+ )} +
+ + {new Date(a.$createdAt).toLocaleDateString("tr-TR")} + +
+ ))} +
+
+ )} +
+ + {/* Matches sidebar */} +
+
+

+ İlgili Müşteriler + {matches.length > 0 && ( + ({matches.length}) + )} +

+ {matches.length === 0 ? ( +

Eşleşme yok.

+ ) : ( +
+ {matches.map((m) => ( +
+ + {customerMap[m.customerId] ?? m.customerId} + + +
+ ))} +
+ )} +
+
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ); +} + +function ScoreBadge({ score }: { score?: number | null }) { + const s = score ?? 0; + const color = + s >= 80 + ? "bg-green-100 text-green-700" + : s >= 60 + ? "bg-blue-100 text-blue-700" + : s >= 40 + ? "bg-yellow-100 text-yellow-700" + : "bg-gray-100 text-gray-500"; + return ( + + {s} + + ); +} diff --git a/src/app/sunum/[token]/page.tsx b/src/app/sunum/[token]/page.tsx index 691b726..7881745 100644 --- a/src/app/sunum/[token]/page.tsx +++ b/src/app/sunum/[token]/page.tsx @@ -9,6 +9,7 @@ import { LISTING_TYPE_LABELS, PROPERTY_STATUS_LABELS, } from "@/lib/appwrite/schema"; +import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils"; interface Props { params: Promise<{ token: string }>; @@ -85,11 +86,22 @@ export default async function SunumPage({ params }: Props) { function PropertyCard({ property: p }: { property: Property }) { const isExpired = p.status === "satildi" || p.status === "kiralandit"; + const imageIds = parseImageIds(p.imageIds); + const coverImageId = imageIds[0]; return (
-
- 🏠 +
+ {coverImageId ? ( + // eslint-disable-next-line @next/next/no-img-element + {p.title} + ) : ( +
🏠
+ )}
diff --git a/src/components/customers/search-form-sheet.tsx b/src/components/customers/search-form-sheet.tsx index 60dc8a1..eb3db09 100644 --- a/src/components/customers/search-form-sheet.tsx +++ b/src/components/customers/search-form-sheet.tsx @@ -24,6 +24,30 @@ import type { Customer, CustomerSearch } from "@/lib/appwrite/schema"; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record }; const INITIAL: ActionState = { ok: false }; +const WEIGHT_OPTIONS = [ + { value: "1", label: "1 — Önemsiz" }, + { value: "2", label: "2 — Az önemli" }, + { value: "3", label: "3 — Orta" }, + { value: "4", label: "4 — Önemli" }, + { value: "5", label: "5 — Çok önemli" }, +]; + +function WeightSelect({ name, defaultValue }: { name: string; defaultValue?: number | null }) { + return ( + + ); +} + interface SearchFormSheetProps { open: boolean; onOpenChange: (v: boolean) => void; @@ -59,7 +83,6 @@ export function SearchFormSheet({ const fe = state.fieldErrors ?? {}; - // Parse existing JSON array fields back to comma-separated strings for display function parseJsonToInput(json?: string | null): string { if (!json) return ""; try { @@ -77,7 +100,8 @@ export function SearchFormSheet({ {search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"} -
+ + {/* Müşteri */}
+ {/* Emlak tipi + ağırlık */}

Virgülle ayırın. Örn: daire, villa

@@ -117,8 +143,13 @@ export function SearchFormSheet({ defaultValue={parseJsonToInput(search?.propertyTypes)} placeholder="daire, villa" /> +
+ Önem: + +
+ {/* Oda sayısı + ağırlık */}

Virgülle ayırın. Örn: 2+1, 3+1

@@ -128,50 +159,76 @@ export function SearchFormSheet({ defaultValue={parseJsonToInput(search?.roomCounts)} placeholder="2+1, 3+1" /> -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - +
+ Önem: +
+ {/* Fiyat aralığı + ağırlık */}
- - + +
+
+ Min + +
+
+ Max + +
+
+
+ Önem: + +
+ {/* M2 aralığı + ağırlık */}
- - + +
+
+ Min + +
+
+ Max + +
+
+
+ Önem: + +
+ {/* Konum + ağırlık */} +
+ +
+ Şehirler (virgülle ayırın) + +
+
+ İlçeler (virgülle ayırın) + +
+
+ Önem: + +
+
+ + {/* Notlar */}