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:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`bg-white rounded-xl border shadow-sm overflow-hidden ${isExpired ? "opacity-60" : ""}`}>
|
||||
<div className="bg-gray-100 h-40 flex items-center justify-center text-4xl text-gray-300">
|
||||
🏠
|
||||
<div className="bg-gray-100 h-48 overflow-hidden">
|
||||
{coverImageId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(coverImageId, 600, 400)}
|
||||
alt={p.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-4xl text-gray-300">🏠</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
||||
@@ -24,6 +24,30 @@ import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||
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 (
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={String(defaultValue ?? 3)}
|
||||
className="border-input bg-background h-8 rounded-md border px-2 text-xs w-40"
|
||||
>
|
||||
{WEIGHT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
||||
<form action={formAction} className="mt-4 space-y-5 pb-6">
|
||||
{/* Müşteri */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Müşteri *</Label>
|
||||
<select
|
||||
@@ -95,6 +119,7 @@ export function SearchFormSheet({
|
||||
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
||||
</div>
|
||||
|
||||
{/* İlan türü */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>İlan türü</Label>
|
||||
<select
|
||||
@@ -108,6 +133,7 @@ export function SearchFormSheet({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Emlak tipi + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="propertyTypes">Emlak tipleri</Label>
|
||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: daire, villa</p>
|
||||
@@ -117,8 +143,13 @@ export function SearchFormSheet({
|
||||
defaultValue={parseJsonToInput(search?.propertyTypes)}
|
||||
placeholder="daire, villa"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="propertyTypeWeight" defaultValue={search?.propertyTypeWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Oda sayısı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="roomCounts">Oda sayıları</Label>
|
||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: 2+1, 3+1</p>
|
||||
@@ -128,50 +159,76 @@ export function SearchFormSheet({
|
||||
defaultValue={parseJsonToInput(search?.roomCounts)}
|
||||
placeholder="2+1, 3+1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiyat aralığı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Fiyat aralığı</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="minPrice">Min fiyat</Label>
|
||||
<Input id="minPrice" name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Min</span>
|
||||
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="maxPrice">Max fiyat</Label>
|
||||
<Input id="maxPrice" name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} />
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Max</span>
|
||||
<Input name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="priceWeight" defaultValue={search?.priceWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M2 aralığı + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>m² aralığı</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="minM2">Min m²</Label>
|
||||
<Input id="minM2" name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Min</span>
|
||||
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="maxM2">Max m²</Label>
|
||||
<Input id="maxM2" name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} />
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Max</span>
|
||||
<Input name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="m2Weight" defaultValue={search?.m2Weight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Konum + ağırlık */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="cities">Şehirler</Label>
|
||||
<Label>Konum</Label>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-muted-foreground text-xs">Şehirler (virgülle ayırın)</span>
|
||||
<Input
|
||||
id="cities"
|
||||
name="cities"
|
||||
defaultValue={parseJsonToInput(search?.cities)}
|
||||
placeholder="İstanbul, Ankara"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="districts">İlçeler</Label>
|
||||
<div className="grid gap-1 mt-1">
|
||||
<span className="text-muted-foreground text-xs">İlçeler (virgülle ayırın)</span>
|
||||
<Input
|
||||
id="districts"
|
||||
name="districts"
|
||||
defaultValue={parseJsonToInput(search?.districts)}
|
||||
placeholder="Kadıköy, Beşiktaş"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||
<WeightSelect name="locationWeight" defaultValue={search?.locationWeight} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notlar */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={search?.notes ?? ""} />
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { scoreMatchBreakdown } from "@/lib/scoring";
|
||||
import type { Property, PropertyMatch, CustomerSearch } from "@/lib/appwrite/schema";
|
||||
|
||||
interface Props {
|
||||
match: PropertyMatch;
|
||||
property: Property | undefined;
|
||||
search: CustomerSearch | undefined;
|
||||
customerName: string;
|
||||
propertyTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function MatchBreakdownDialog({
|
||||
match,
|
||||
property,
|
||||
search,
|
||||
customerName,
|
||||
propertyTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const breakdown =
|
||||
property && search ? scoreMatchBreakdown(property, search) : null;
|
||||
|
||||
const pctColor = (r: number) =>
|
||||
r >= 0.8
|
||||
? "text-green-600"
|
||||
: r >= 0.5
|
||||
? "text-blue-600"
|
||||
: r >= 0.3
|
||||
? "text-yellow-600"
|
||||
: "text-red-500";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Eşleşme Detayı</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold">{customerName}</p>
|
||||
<p className="text-muted-foreground text-sm">{propertyTitle}</p>
|
||||
</div>
|
||||
<ScoreCircle score={match.score ?? 0} />
|
||||
</div>
|
||||
|
||||
{!breakdown && (
|
||||
<p className="text-muted-foreground text-sm">Kırılım verisi bulunamadı.</p>
|
||||
)}
|
||||
|
||||
{breakdown && (
|
||||
<>
|
||||
{breakdown.criteria.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bu aramada kriter belirtilmemiş — her ilan 100 puan alır.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{breakdown.criteria.map((c) => (
|
||||
<div key={c.label} className="grid grid-cols-[1fr_auto_auto] items-start gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<span>{c.label}</span>
|
||||
<span className="text-muted-foreground text-xs">(ağırlık: {c.weight})</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">{c.note}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-sm font-semibold ${pctColor(c.ratio)}`}>
|
||||
%{Math.round(c.ratio * 100)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{c.earned.toFixed(1)}/{c.weight}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t pt-2 grid grid-cols-[1fr_auto_auto] gap-3">
|
||||
<span className="text-sm font-semibold">Toplam</span>
|
||||
<span className="text-right text-sm font-semibold">
|
||||
%{breakdown.score}
|
||||
</span>
|
||||
<span className="text-right text-xs text-muted-foreground">
|
||||
{breakdown.total.toFixed(1)}/{breakdown.maxPossible}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreCircle({ score }: { score: number }) {
|
||||
const color =
|
||||
score >= 80
|
||||
? "bg-green-100 text-green-700"
|
||||
: score >= 60
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: score >= 40
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex size-12 shrink-0 items-center justify-center rounded-full text-lg font-bold ${color}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MatchBreakdownDialog } from "./match-breakdown-dialog";
|
||||
import type { Property, PropertyMatch, CustomerSearch, Customer } from "@/lib/appwrite/schema";
|
||||
|
||||
interface MatchesClientProps {
|
||||
matches: PropertyMatch[];
|
||||
customers: Customer[];
|
||||
properties: Property[];
|
||||
searches: CustomerSearch[];
|
||||
}
|
||||
|
||||
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 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchesClient({ matches, customers, properties, searches }: MatchesClientProps) {
|
||||
const [selectedMatch, setSelectedMatch] = useState<PropertyMatch | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
|
||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||
|
||||
function openBreakdown(m: PropertyMatch) {
|
||||
setSelectedMatch(m);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<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="p-3 text-left font-medium">Puan</th>
|
||||
<th className="p-3 text-left font-medium">Müşteri</th>
|
||||
<th className="p-3 text-left font-medium">İlan</th>
|
||||
<th className="p-3 text-left font-medium">Tarih</th>
|
||||
<th className="p-3 text-left font-medium">Görüntülendi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted-foreground py-10 text-center">
|
||||
Henüz eşleşme yok.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{matches.map((m) => {
|
||||
const customer = customerMap[m.customerId];
|
||||
const property = propertyMap[m.propertyId];
|
||||
return (
|
||||
<tr
|
||||
key={m.$id}
|
||||
className="hover:bg-muted/30 cursor-pointer border-b last:border-0"
|
||||
onClick={() => openBreakdown(m)}
|
||||
title="Eşleşme kırılımını görmek için tıklayın"
|
||||
>
|
||||
<td className="p-3">
|
||||
<ScoreBadge score={m.score} />
|
||||
</td>
|
||||
<td className="p-3">{customer?.name ?? m.customerId}</td>
|
||||
<td className="p-3">{property?.title ?? 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-xs text-green-600">
|
||||
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Hayır</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMatch && (
|
||||
<MatchBreakdownDialog
|
||||
match={selectedMatch}
|
||||
property={propertyMap[selectedMatch.propertyId]}
|
||||
search={searchMap[selectedMatch.searchId]}
|
||||
customerName={customerMap[selectedMatch.customerId]?.name ?? selectedMatch.customerId}
|
||||
propertyTitle={propertyMap[selectedMatch.propertyId]?.title ?? selectedMatch.propertyId}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -108,6 +109,12 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/properties/${p.$id}`}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Detay
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openEdit(p)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Düzenle
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { createPropertyAction, updatePropertyAction } from "@/lib/appwrite/property-actions";
|
||||
import { PropertyImageUploader } from "./property-image-uploader";
|
||||
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import type { Property } from "@/lib/appwrite/schema";
|
||||
|
||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||
@@ -168,6 +170,14 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
|
||||
<Textarea id="description" name="description" rows={3} defaultValue={property?.description ?? ""} placeholder="İlan detayları..." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Fotoğraflar</Label>
|
||||
<PropertyImageUploader
|
||||
name="imageIds"
|
||||
initialImageIds={parseImageIds(property?.imageIds)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Upload, X, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { uploadPropertyImageAction, deletePropertyImageAction } from "@/lib/appwrite/storage-actions";
|
||||
import { getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
|
||||
|
||||
interface PropertyImageUploaderProps {
|
||||
name: string;
|
||||
initialImageIds?: string[];
|
||||
}
|
||||
|
||||
export function PropertyImageUploader({ name, initialImageIds = [] }: PropertyImageUploaderProps) {
|
||||
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const result = await uploadPropertyImageAction(fd);
|
||||
if (result.ok && result.fileId) {
|
||||
setImageIds((prev) => [...prev, result.fileId!]);
|
||||
} else {
|
||||
toast.error(result.error ?? "Yükleme başarısız");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
const result = await deletePropertyImageAction(fileId);
|
||||
if (result.ok) {
|
||||
setImageIds((prev) => prev.filter((id) => id !== fileId));
|
||||
} else {
|
||||
toast.error(result.error ?? "Fotoğraf silinemedi");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input type="hidden" name={name} value={JSON.stringify(imageIds)} />
|
||||
|
||||
{imageIds.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{imageIds.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="group relative aspect-video rounded-md overflow-hidden border bg-muted"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(id, 400, 300)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(id)}
|
||||
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded-full bg-red-500 text-white group-hover:flex"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 rounded-md border border-dashed px-4 py-2.5 text-sm text-muted-foreground hover:bg-muted/50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
{uploading ? "Yükleniyor..." : "Fotoğraf ekle"}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,12 @@ export async function listCustomers(tenantId: string): Promise<Customer[]> {
|
||||
tableId: TABLES.customers,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(500)],
|
||||
});
|
||||
return result.rows as unknown as Customer[];
|
||||
return JSON.parse(JSON.stringify(result.rows)) as Customer[];
|
||||
}
|
||||
|
||||
export async function getCustomer(id: string, tenantId: string): Promise<Customer | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.customers, id)) as unknown as Customer;
|
||||
if (row.tenantId !== tenantId) return null;
|
||||
return row;
|
||||
return JSON.parse(JSON.stringify(row)) as Customer;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ export async function createCustomerSearchAction(
|
||||
districts: toJsonList(data.districts),
|
||||
isActive: true,
|
||||
notes: data.notes,
|
||||
priceWeight: data.priceWeight,
|
||||
m2Weight: data.m2Weight,
|
||||
locationWeight: data.locationWeight,
|
||||
roomCountWeight: data.roomCountWeight,
|
||||
propertyTypeWeight: data.propertyTypeWeight,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
[
|
||||
@@ -117,6 +122,11 @@ export async function updateCustomerSearchAction(
|
||||
cities: toJsonList(data.cities),
|
||||
districts: toJsonList(data.districts),
|
||||
notes: data.notes,
|
||||
priceWeight: data.priceWeight,
|
||||
m2Weight: data.m2Weight,
|
||||
locationWeight: data.locationWeight,
|
||||
roomCountWeight: data.roomCountWeight,
|
||||
propertyTypeWeight: data.propertyTypeWeight,
|
||||
});
|
||||
|
||||
await syncMatchesForSearch(ctx.tenantId, ctx.user.id);
|
||||
|
||||
@@ -4,53 +4,11 @@ import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Property, type CustomerSearch } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { scoreMatch } from "@/lib/scoring";
|
||||
|
||||
function priceMatches(price: number, min?: number | null, max?: number | null): boolean {
|
||||
if (min != null && price < min) return false;
|
||||
if (max != null && price > max) return false;
|
||||
return true;
|
||||
}
|
||||
export { scoreMatch };
|
||||
|
||||
function m2Matches(m2?: number | null, min?: number | null, max?: number | null): boolean {
|
||||
if (!m2) return true;
|
||||
if (min != null && m2 < min) return false;
|
||||
if (max != null && m2 > max) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function listMatches(value: string, jsonList?: string | null): boolean {
|
||||
if (!jsonList) return true;
|
||||
try {
|
||||
const arr = JSON.parse(jsonList) as string[];
|
||||
if (!arr.length) return true;
|
||||
return arr.includes(value);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function cityMatches(city: string, jsonCities?: string | null): boolean {
|
||||
if (!jsonCities) return true;
|
||||
try {
|
||||
const arr = JSON.parse(jsonCities) as string[];
|
||||
if (!arr.length) return true;
|
||||
return arr.some((c) => c.toLowerCase() === city.toLowerCase());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function districtMatches(district?: string | null, jsonDistricts?: string | null): boolean {
|
||||
if (!jsonDistricts) return true;
|
||||
try {
|
||||
const arr = JSON.parse(jsonDistricts) as string[];
|
||||
if (!arr.length) return true;
|
||||
if (!district) return false;
|
||||
return arr.some((d) => d.toLowerCase() === district.toLowerCase());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const SCORE_THRESHOLD = 20;
|
||||
|
||||
export async function matchPropertyToSearches(
|
||||
property: Property,
|
||||
@@ -75,14 +33,10 @@ export async function matchPropertyToSearches(
|
||||
|
||||
for (const search of searches) {
|
||||
if (search.listingType && search.listingType !== property.listingType) continue;
|
||||
if (!listMatches(property.propertyType, search.propertyTypes)) continue;
|
||||
if (property.roomCount && !listMatches(property.roomCount, search.roomCounts)) continue;
|
||||
if (!priceMatches(property.price, search.minPrice, search.maxPrice)) continue;
|
||||
if (!m2Matches(property.netM2, search.minM2, search.maxM2)) continue;
|
||||
if (!cityMatches(property.city, search.cities)) continue;
|
||||
if (!districtMatches(property.district, search.districts)) continue;
|
||||
|
||||
// duplicate kontrolü
|
||||
const score = scoreMatch(property, search);
|
||||
if (score < SCORE_THRESHOLD) continue;
|
||||
|
||||
const existing = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
@@ -92,8 +46,10 @@ export async function matchPropertyToSearches(
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (existing.rows.length > 0) continue;
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existing.rows[0].$id, { score });
|
||||
} else {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.propertyMatches,
|
||||
@@ -104,6 +60,7 @@ export async function matchPropertyToSearches(
|
||||
customerId: search.customerId,
|
||||
searchId: search.$id,
|
||||
notified: false,
|
||||
score,
|
||||
createdBy,
|
||||
},
|
||||
[
|
||||
@@ -115,3 +72,4 @@ export async function matchPropertyToSearches(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ export async function listProperties(tenantId: string): Promise<Property[]> {
|
||||
tableId: TABLES.properties,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(200)],
|
||||
});
|
||||
return result.rows as unknown as Property[];
|
||||
return JSON.parse(JSON.stringify(result.rows)) as Property[];
|
||||
}
|
||||
|
||||
export async function getProperty(id: string, tenantId: string): Promise<Property | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.properties, id)) as unknown as Property;
|
||||
if (row.tenantId !== tenantId) return null;
|
||||
return row;
|
||||
return JSON.parse(JSON.stringify(row)) as Property;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,12 @@ export interface CustomerSearch extends Row {
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
// ağırlıklar: 1=önemsiz … 5=çok önemli
|
||||
priceWeight?: number;
|
||||
m2Weight?: number;
|
||||
locationWeight?: number;
|
||||
roomCountWeight?: number;
|
||||
propertyTypeWeight?: number;
|
||||
}
|
||||
|
||||
export interface PropertyMatch extends Row {
|
||||
@@ -114,6 +120,7 @@ export interface PropertyMatch extends Row {
|
||||
searchId: string;
|
||||
notified?: boolean;
|
||||
viewedAt?: string;
|
||||
score?: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
import { InputFile } from "node-appwrite/file";
|
||||
|
||||
import { BUCKETS } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
type UploadResult = { ok: boolean; fileId?: string; error?: string };
|
||||
type DeleteResult = { ok: boolean; error?: string };
|
||||
|
||||
export async function uploadPropertyImageAction(formData: FormData): Promise<UploadResult> {
|
||||
const ctx = await requireTenant();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { ok: false, error: "Dosya seçilmedi." };
|
||||
|
||||
const { storage } = createAdminClient();
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||
|
||||
const result = await storage.createFile(
|
||||
BUCKETS.propertyImages,
|
||||
ID.unique(),
|
||||
inputFile,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
Permission.delete(Role.team(ctx.tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
|
||||
return { ok: true, fileId: result.$id };
|
||||
} catch (e) {
|
||||
console.error("Image upload error:", e);
|
||||
return { ok: false, error: "Fotoğraf yüklenemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePropertyImageAction(fileId: string): Promise<DeleteResult> {
|
||||
await requireTenant();
|
||||
const { storage } = createAdminClient();
|
||||
|
||||
try {
|
||||
await storage.deleteFile(BUCKETS.propertyImages, fileId);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Fotoğraf silinemedi." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function getPropertyImageUrl(fileId: string): string {
|
||||
const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, "");
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
|
||||
return `${endpoint}/storage/buckets/property-images/files/${fileId}/view?project=${projectId}`;
|
||||
}
|
||||
|
||||
export function getPropertyImagePreviewUrl(fileId: string, width = 800, height = 600): string {
|
||||
const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, "");
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
|
||||
return `${endpoint}/storage/buckets/property-images/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=85`;
|
||||
}
|
||||
|
||||
export function parseImageIds(imageIds?: string | null): string[] {
|
||||
if (!imageIds) return [];
|
||||
try {
|
||||
const arr = JSON.parse(imageIds);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Property, CustomerSearch } from "./appwrite/schema";
|
||||
|
||||
const DEFAULT_WEIGHT = 3;
|
||||
|
||||
export const ROOM_ORDER = [
|
||||
"Stüdyo",
|
||||
"1+0",
|
||||
"1+1",
|
||||
"2+1",
|
||||
"3+1",
|
||||
"4+1",
|
||||
"4+2",
|
||||
"5+1",
|
||||
"5+2",
|
||||
"6+",
|
||||
];
|
||||
|
||||
export interface CriterionResult {
|
||||
label: string;
|
||||
weight: number;
|
||||
ratio: number;
|
||||
earned: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
total: number;
|
||||
maxPossible: number;
|
||||
score: number;
|
||||
criteria: CriterionResult[];
|
||||
}
|
||||
|
||||
export function tryParseJsonArray(json: string): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(json);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function roomCountRatio(
|
||||
roomCount: string | undefined | null,
|
||||
desired: string[],
|
||||
): { ratio: number; note: string } {
|
||||
if (!roomCount) return { ratio: 0.5, note: "İlanda oda sayısı belirtilmemiş" };
|
||||
if (desired.includes(roomCount)) return { ratio: 1.0, note: `${roomCount} tam eşleşme` };
|
||||
|
||||
const propIdx = ROOM_ORDER.indexOf(roomCount);
|
||||
if (propIdx === -1) return { ratio: 0.0, note: `${roomCount} listede yok` };
|
||||
|
||||
let minDist = Infinity;
|
||||
for (const d of desired) {
|
||||
const dIdx = ROOM_ORDER.indexOf(d);
|
||||
if (dIdx !== -1) minDist = Math.min(minDist, Math.abs(propIdx - dIdx));
|
||||
}
|
||||
|
||||
if (minDist === 1) return { ratio: 0.5, note: `${roomCount} — yakın oda sayısı (1 adım)` };
|
||||
if (minDist === 2) return { ratio: 0.25, note: `${roomCount} — yakın oda sayısı (2 adım)` };
|
||||
return { ratio: 0.0, note: `${roomCount} istenenden çok farklı` };
|
||||
}
|
||||
|
||||
function priceRatio(
|
||||
price: number,
|
||||
minPrice?: number | null,
|
||||
maxPrice?: number | null,
|
||||
): { ratio: number; note: string } {
|
||||
const fmt = (n: number) => n.toLocaleString("tr-TR");
|
||||
if (minPrice != null && price < minPrice) {
|
||||
return { ratio: 0.9, note: `${fmt(price)} ₺ — minimum bütçenin altında` };
|
||||
}
|
||||
if (maxPrice != null && price > maxPrice) {
|
||||
const overage = ((price - maxPrice) / maxPrice) * 100;
|
||||
if (overage <= 10) return { ratio: 0.7, note: `${fmt(price)} ₺ — bütçenin %${overage.toFixed(0)} üstünde` };
|
||||
if (overage <= 20) return { ratio: 0.4, note: `${fmt(price)} ₺ — bütçenin %${overage.toFixed(0)} üstünde` };
|
||||
if (overage <= 30) return { ratio: 0.2, note: `${fmt(price)} ₺ — bütçenin %${overage.toFixed(0)} üstünde` };
|
||||
return { ratio: 0.0, note: `${fmt(price)} ₺ — bütçeyi çok aşıyor (%${overage.toFixed(0)})` };
|
||||
}
|
||||
const range = [minPrice != null ? `min ${fmt(minPrice)}` : null, maxPrice != null ? `max ${fmt(maxPrice)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" – ");
|
||||
return { ratio: 1.0, note: `${fmt(price)} ₺ — bütçe uygun (${range})` };
|
||||
}
|
||||
|
||||
function m2Ratio(
|
||||
m2: number,
|
||||
minM2?: number | null,
|
||||
maxM2?: number | null,
|
||||
): { ratio: number; note: string } {
|
||||
if (minM2 != null && m2 < minM2) {
|
||||
const shortage = ((minM2 - m2) / minM2) * 100;
|
||||
if (shortage <= 10) return { ratio: 0.7, note: `${m2} m² — minimum m²'nin %${shortage.toFixed(0)} altında` };
|
||||
if (shortage <= 20) return { ratio: 0.4, note: `${m2} m² — minimum m²'nin %${shortage.toFixed(0)} altında` };
|
||||
return { ratio: 0.0, note: `${m2} m² — minimum m²'den çok küçük` };
|
||||
}
|
||||
if (maxM2 != null && m2 > maxM2) {
|
||||
return { ratio: 0.9, note: `${m2} m² — maksimumdan büyük ama kabul edilebilir` };
|
||||
}
|
||||
return { ratio: 1.0, note: `${m2} m² — m² uygun` };
|
||||
}
|
||||
|
||||
function locationScore(
|
||||
property: Property,
|
||||
search: CustomerSearch,
|
||||
): { ratio: number; note: string } {
|
||||
const cities = tryParseJsonArray(search.cities ?? "");
|
||||
const districts = tryParseJsonArray(search.districts ?? "");
|
||||
|
||||
const cityMatch =
|
||||
cities.length === 0 ||
|
||||
cities.some((c) => c.toLowerCase() === property.city.toLowerCase());
|
||||
|
||||
if (!cityMatch) {
|
||||
return { ratio: 0.0, note: `${property.city} — istenen şehirlerde yok (${cities.join(", ")})` };
|
||||
}
|
||||
|
||||
if (districts.length === 0) {
|
||||
return { ratio: 1.0, note: `${property.city} — şehir eşleşiyor` };
|
||||
}
|
||||
|
||||
const districtMatch =
|
||||
property.district &&
|
||||
districts.some((d) => d.toLowerCase() === property.district!.toLowerCase());
|
||||
|
||||
if (districtMatch) {
|
||||
return { ratio: 1.0, note: `${property.district}, ${property.city} — tam konum eşleşmesi` };
|
||||
}
|
||||
|
||||
return {
|
||||
ratio: 0.4,
|
||||
note: `${property.city} — şehir eşleşiyor ama ${property.district ?? "ilçe yok"} istenenden farklı (${districts.join(", ")})`,
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreMatchBreakdown(property: Property, search: CustomerSearch): ScoreBreakdown {
|
||||
const criteria: CriterionResult[] = [];
|
||||
let total = 0;
|
||||
let maxPossible = 0;
|
||||
|
||||
function addCriterion(
|
||||
label: string,
|
||||
weight: number | undefined | null,
|
||||
ratio: number,
|
||||
note: string,
|
||||
) {
|
||||
const wt = weight ?? DEFAULT_WEIGHT;
|
||||
const earned = wt * ratio;
|
||||
total += earned;
|
||||
maxPossible += wt;
|
||||
criteria.push({ label, weight: wt, ratio, earned, note });
|
||||
}
|
||||
|
||||
const propTypes = search.propertyTypes ? tryParseJsonArray(search.propertyTypes) : [];
|
||||
if (propTypes.length > 0) {
|
||||
const matches = propTypes.includes(property.propertyType);
|
||||
addCriterion(
|
||||
"Emlak tipi",
|
||||
search.propertyTypeWeight,
|
||||
matches ? 1.0 : 0.0,
|
||||
matches
|
||||
? `${property.propertyType} — tam eşleşme`
|
||||
: `${property.propertyType} — istenmiyor (${propTypes.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
const roomCounts = search.roomCounts ? tryParseJsonArray(search.roomCounts) : [];
|
||||
if (roomCounts.length > 0) {
|
||||
const { ratio, note } = roomCountRatio(property.roomCount, roomCounts);
|
||||
addCriterion("Oda sayısı", search.roomCountWeight, ratio, note);
|
||||
}
|
||||
|
||||
if (search.minPrice != null || search.maxPrice != null) {
|
||||
const { ratio, note } = priceRatio(property.price, search.minPrice, search.maxPrice);
|
||||
addCriterion("Fiyat", search.priceWeight, ratio, note);
|
||||
}
|
||||
|
||||
if (search.minM2 != null || search.maxM2 != null) {
|
||||
const m2 = property.netM2 ?? property.grossM2;
|
||||
if (m2 != null) {
|
||||
const { ratio, note } = m2Ratio(m2, search.minM2, search.maxM2);
|
||||
addCriterion("m²", search.m2Weight, ratio, note);
|
||||
} else {
|
||||
addCriterion("m²", search.m2Weight, 0.5, "İlanda m² bilgisi yok");
|
||||
}
|
||||
}
|
||||
|
||||
const cities = search.cities ? tryParseJsonArray(search.cities) : [];
|
||||
if (cities.length > 0) {
|
||||
const { ratio, note } = locationScore(property, search);
|
||||
addCriterion("Konum", search.locationWeight, ratio, note);
|
||||
}
|
||||
|
||||
const score =
|
||||
maxPossible === 0 ? 100 : Math.round((total / maxPossible) * 100);
|
||||
|
||||
return { total, maxPossible, score, criteria };
|
||||
}
|
||||
|
||||
export function scoreMatch(property: Property, search: CustomerSearch): number {
|
||||
return scoreMatchBreakdown(property, search).score;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const weightField = z.coerce.number().int().min(1).max(5).optional().nullable();
|
||||
|
||||
export const customerSearchSchema = z.object({
|
||||
customerId: z.string().min(1, "Müşteri seçin"),
|
||||
listingType: z.enum(["satilik", "kiralik"]).optional().or(z.literal("")),
|
||||
@@ -13,6 +15,11 @@ export const customerSearchSchema = z.object({
|
||||
districts: z.string().max(1000).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
notes: z.string().max(5000).optional(),
|
||||
priceWeight: weightField,
|
||||
m2Weight: weightField,
|
||||
locationWeight: weightField,
|
||||
roomCountWeight: weightField,
|
||||
propertyTypeWeight: weightField,
|
||||
});
|
||||
|
||||
export type CustomerSearchFormValues = z.infer<typeof customerSearchSchema>;
|
||||
|
||||
Reference in New Issue
Block a user