a40e68254b
- 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
263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
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} m²`} />}
|
||
{property.grossM2 && <Detail label="Brüt m²" value={`${property.grossM2} m²`} />}
|
||
{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>
|
||
);
|
||
}
|