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
This commit is contained in:
egecankomur
2026-05-05 19:55:34 +03:00
parent 3d044c5d5b
commit a40e68254b
22 changed files with 1105 additions and 169 deletions
+1 -1
View File
@@ -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 (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
+27 -49
View File
@@ -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 (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
<span className="text-muted-foreground text-sm">{matches.length} eşleşme</span>
</div>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Müşteri</th>
<th className="text-left p-3 font-medium">İlan</th>
<th className="text-left p-3 font-medium">Tarih</th>
<th className="text-left p-3 font-medium">Görüntülendi</th>
</tr>
</thead>
<tbody>
{matches.length === 0 && (
<tr>
<td colSpan={4} className="text-muted-foreground text-center py-10">
Henüz eşleşme yok.
</td>
</tr>
)}
{matches.map((m) => (
<tr key={m.$id} className="border-b last:border-0 hover:bg-muted/30">
<td className="p-3">{customerMap[m.customerId] ?? m.customerId}</td>
<td className="p-3">{propertyMap[m.propertyId] ?? m.propertyId}</td>
<td className="p-3 text-muted-foreground">
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
</td>
<td className="p-3">
{m.viewedAt ? (
<span className="text-green-600 text-xs">
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
</span>
) : (
<span className="text-muted-foreground text-xs">Hayır</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<MatchesClient
matches={matches}
customers={customers}
properties={properties}
searches={searches}
/>
</div>
);
}
@@ -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 (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
+1 -1
View File
@@ -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 (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
+1 -1
View File
@@ -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 (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
@@ -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<string, string> = {
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 (
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
{/* Header */}
<div className="flex items-center gap-3">
<Link
href="/properties"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm"
>
<ArrowLeft className="size-4" />
İlanlar
</Link>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{property.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{[property.neighborhood, property.district, property.city].filter(Boolean).join(", ")}
</p>
</div>
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${statusColor[property.status] ?? "bg-gray-100 text-gray-600"}`}
>
{PROPERTY_STATUS_LABELS[property.status] ?? property.status}
</span>
</div>
{/* Photo gallery */}
{imageIds.length > 0 && (
<div>
<div
className={`grid gap-2 ${
imageIds.length === 1
? "grid-cols-1"
: imageIds.length === 2
? "grid-cols-2"
: "grid-cols-3"
}`}
>
{imageIds.map((fileId, i) => (
<div
key={fileId}
className={`overflow-hidden rounded-lg border bg-muted ${i === 0 && imageIds.length > 2 ? "col-span-2 row-span-2" : ""}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(fileId, 1200, 900)}
alt={`${property.title} fotoğraf ${i + 1}`}
className="h-full w-full object-cover"
style={{ maxHeight: i === 0 && imageIds.length > 2 ? "480px" : "240px" }}
/>
</div>
))}
</div>
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{/* Price */}
<div className="md:col-span-2 space-y-6">
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{property.price.toLocaleString("tr-TR")}
</span>
<span className="text-muted-foreground">{property.currency ?? "TRY"}</span>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<Detail label="Emlak tipi" value={PROPERTY_TYPE_LABELS[property.propertyType]} />
<Detail label="İlan türü" value={LISTING_TYPE_LABELS[property.listingType]} />
{property.roomCount && <Detail label="Oda sayısı" value={property.roomCount} />}
{property.netM2 && <Detail label="Net m²" value={`${property.netM2}`} />}
{property.grossM2 && <Detail label="Brüt m²" value={`${property.grossM2}`} />}
{property.floor != null && <Detail label="Kat" value={String(property.floor)} />}
{property.totalFloors != null && (
<Detail label="Top. kat" value={String(property.totalFloors)} />
)}
{property.buildingAge != null && (
<Detail label="Bina yaşı" value={`${property.buildingAge} yıl`} />
)}
</div>
{property.address && (
<div className="text-sm">
<span className="text-muted-foreground">Adres: </span>
{property.address}
</div>
)}
</div>
{property.description && (
<div className="rounded-lg border p-4">
<h2 className="mb-2 text-sm font-semibold">Açıklama</h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{property.description}
</p>
</div>
)}
{/* Activities */}
{activities.length > 0 && (
<div className="rounded-lg border p-4">
<h2 className="mb-3 text-sm font-semibold">Aktiviteler</h2>
<div className="space-y-2">
{activities.map((a) => (
<div key={a.$id} className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground mt-0.5 shrink-0">
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
</span>
<div className="min-w-0">
<p className="font-medium truncate">{a.title}</p>
{a.description && (
<p className="text-muted-foreground text-xs truncate">{a.description}</p>
)}
</div>
<span className="text-muted-foreground text-xs ml-auto shrink-0">
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Matches sidebar */}
<div>
<div className="rounded-lg border p-4">
<h2 className="mb-3 text-sm font-semibold">
İlgili Müşteriler
{matches.length > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">({matches.length})</span>
)}
</h2>
{matches.length === 0 ? (
<p className="text-muted-foreground text-xs">Eşleşme yok.</p>
) : (
<div className="space-y-2">
{matches.map((m) => (
<div key={m.$id} className="flex items-center justify-between gap-2">
<span className="text-sm truncate">
{customerMap[m.customerId] ?? m.customerId}
</span>
<ScoreBadge score={m.score} />
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground">{label}: </span>
<span className="font-medium">{value}</span>
</div>
);
}
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 (
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
>
{s}
</span>
);
}