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
@@ -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>
);
}