Compare commits

..

3 Commits

Author SHA1 Message Date
egecankomur f043f4acd7 feat: redesign login page — split layout, Emlak CRM + Kovak Yazılım branding
- Sol: koyu panel — logo, özellikler listesi (portföy/eşleşme/sunum/müşteri), alt imza
- Sağ: temiz form — email + şifre, mobil responsive
- İşletmem referansları kaldırıldı, Emlak CRM olarak güncellendi
2026-05-05 20:10:01 +03:00
egecankomur d9aff26376 fix: delete stale matches when score drops below threshold or listing type changes
matchPropertyToSearches now:
- scores every search (listing type mismatch = 0 score)
- score >= 20: create or update match
- score < 20 AND existing match: delete stale record

Prevents outdated match records after criteria/weight updates.
2026-05-05 20:03:49 +03:00
egecankomur a40e68254b 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
2026-05-05 19:55:34 +03:00
25 changed files with 1276 additions and 307 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "İşletmem — Giriş",
description: "İşletmem KovakCRM hesabınıza giriş yapın veya yeni hesap oluşturun.",
title: "Emlak CRM — Giriş",
description: "Emlak CRM hesabınıza giriş yapın.",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
@@ -2,13 +2,11 @@
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, Building2, Users, Presentation, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import { signInAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
@@ -21,35 +19,120 @@ export function LoginForm1({
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form action={formAction} className="p-6 md:p-10">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="flex flex-col gap-6">
<div className="flex justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
<div className={cn("flex min-h-svh w-full", className)} {...props}>
{/* ── Sol: Marka paneli ── */}
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-slate-900 p-12 text-white lg:flex">
{/* Arka plan dekorasyon */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle at 15% 15%, rgba(59,130,246,0.25) 0%, transparent 45%), radial-gradient(circle at 85% 80%, rgba(99,102,241,0.2) 0%, transparent 50%)",
}}
aria-hidden
/>
<div
className="pointer-events-none absolute -top-32 -right-32 size-96 rounded-full bg-blue-500/10 blur-3xl"
aria-hidden
/>
<div
className="pointer-events-none absolute -bottom-40 -left-24 size-[28rem] rounded-full bg-indigo-600/10 blur-3xl"
aria-hidden
/>
{/* Logo + Ürün adı */}
<div className="relative z-10 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-blue-500/20 ring-1 ring-blue-400/30 backdrop-blur">
<Building2 className="size-5 text-blue-300" />
</div>
<span className="text-xl font-semibold">İşletmem</span>
</Link>
<div>
<p className="text-lg font-bold tracking-tight leading-none">Emlak CRM</p>
<p className="text-xs text-slate-400 leading-none mt-0.5">Kovak Yazılım</p>
</div>
</div>
{/* Orta içerik */}
<div className="relative z-10 space-y-8">
<div className="space-y-3">
<h2 className="text-3xl font-bold leading-snug tracking-tight">
Gayrimenkul süreçlerinizi tek platformdan yönetin
</h2>
<p className="text-slate-400 text-sm leading-relaxed">
İlanlar, müşteriler, akıllı eşleşme ve sunumlar ekibinizle birlikte, her yerden.
</p>
</div>
<ul className="space-y-4">
{[
{
icon: Building2,
title: "Portföy yönetimi",
desc: "Tüm ilanlarınızı fotoğraflarıyla ekleyin, takip edin",
},
{
icon: Zap,
title: "Akıllı eşleşme",
desc: "Ağırlıklı puanlama ile müşteri × ilan eşleştirmesi",
},
{
icon: Presentation,
title: "Sunum paylaşımı",
desc: "Müşteriye özel sunum linkleri oluşturun ve gönderin",
},
{
icon: Users,
title: "Müşteri & arama",
desc: "Alıcı ve kiracıların kriterlerini saklayın",
},
].map(({ icon: Icon, title, desc }) => (
<li key={title} className="flex items-start gap-3">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 ring-1 ring-blue-400/20">
<Icon className="size-3.5 text-blue-300" />
</div>
<div>
<p className="text-sm font-medium leading-none">{title}</p>
<p className="mt-0.5 text-xs text-slate-400">{desc}</p>
</div>
</li>
))}
</ul>
</div>
{/* Alt: Kovak Yazılım */}
<div className="relative z-10 flex flex-col gap-0.5">
<p className="text-xs font-semibold text-slate-300">Emlak CRM</p>
<p className="text-xs text-slate-500">Kovak Yazılım · kovaksoft.com</p>
</div>
</div>
{/* ── Sağ: Giriş formu ── */}
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-10 lg:w-1/2 dark:bg-slate-950">
<div className="w-full max-w-sm space-y-8">
{/* Mobilde logo */}
<div className="flex items-center gap-2 lg:hidden">
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
<Building2 className="size-4" />
</div>
<span className="font-bold">Emlak CRM</span>
</div>
<div className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
<p className="text-muted-foreground text-sm">
Hesabınıza giriş yaparak devam edin
</p>
</div>
{inviteCode && (
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
<p className="rounded-md border bg-blue-50 px-3 py-2 text-center text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-300">
Davete katılmak için giriş yapın.
</p>
)}
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
Hesabınıza giriş yaparak işletmenizi yönetmeye devam edin
</p>
</div>
<form action={formAction} className="space-y-5">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
@@ -61,12 +144,12 @@ export function LoginForm1({
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Şifre</Label>
<Link
href="/forgot-password"
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
>
Şifremi unuttum
</Link>
@@ -81,7 +164,7 @@ export function LoginForm1({
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
<p className="text-destructive text-sm" role="alert">
{state.error}
</p>
)}
@@ -96,8 +179,9 @@ export function LoginForm1({
"Giriş yap"
)}
</Button>
</form>
<div className="text-center text-sm text-muted-foreground">
<p className="text-muted-foreground text-center text-sm">
Hesabınız yok mu?{" "}
<Link
href="/sign-up"
@@ -105,64 +189,13 @@ export function LoginForm1({
>
Hesap oluştur
</Link>
</div>
</div>
</form>
<BrandPanel />
</CardContent>
</Card>
<p className="text-muted-foreground text-center text-xs text-balance">
Giriş yaparak{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Kullanım Şartları
</Link>{" "}
ve{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Gizlilik Politikası
</Link>
&apos;nı kabul etmiş olursunuz.
</p>
</div>
);
}
function BrandPanel() {
return (
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
}}
aria-hidden
/>
<div
className="absolute -top-24 -right-24 size-72 rounded-full bg-white/10 blur-3xl"
aria-hidden
/>
<div
className="absolute -bottom-32 -left-20 size-80 rounded-full bg-black/10 blur-3xl"
aria-hidden
/>
<div className="relative z-10 flex items-center gap-2">
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-lg font-medium">İşletmem</span>
</div>
<div className="relative z-10 flex flex-col gap-3">
<h2 className="text-3xl font-semibold leading-tight">
Müşteriden faturaya, tek panelden işletmenizi yönetin.
</h2>
<p className="text-primary-foreground/80 text-sm">
Müşteriler, hizmetler, takvim, görevler ve finans hepsi tek yerde, multi-tenant ve ekibinize özel.
{/* Alt logo — sadece geniş ekranda gizli olan mobil için */}
<p className="mt-10 text-xs text-muted-foreground lg:hidden">
Emlak CRM · Kovak Yazılım
</p>
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
</div>
</div>
);
+1 -7
View File
@@ -12,11 +12,5 @@ export default async function Page({
const user = await getCurrentUser();
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm1 inviteCode={invite} />
</div>
</div>
);
return <LoginForm1 inviteCode={invite} />;
}
+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>
);
}
+14 -2
View File
@@ -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">
+77 -20
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+2 -2
View File
@@ -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);
+18 -54
View File
@@ -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,
@@ -74,15 +32,10 @@ export async function matchPropertyToSearches(
const searches = searchesResult.rows as unknown as CustomerSearch[];
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;
const listingTypeMismatch =
!!search.listingType && search.listingType !== property.listingType;
const score = listingTypeMismatch ? 0 : scoreMatch(property, search);
// duplicate kontrolü
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.propertyMatches,
@@ -92,8 +45,13 @@ export async function matchPropertyToSearches(
Query.limit(1),
],
});
if (existing.rows.length > 0) continue;
const existingId = existing.rows.length > 0 ? existing.rows[0].$id : null;
if (score >= SCORE_THRESHOLD) {
if (existingId) {
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existingId, { score });
} else {
await tablesDB.createRow(
DATABASE_ID,
TABLES.propertyMatches,
@@ -104,6 +62,7 @@ export async function matchPropertyToSearches(
customerId: search.customerId,
searchId: search.$id,
notified: false,
score,
createdBy,
},
[
@@ -114,4 +73,9 @@ export async function matchPropertyToSearches(
],
);
}
} else if (existingId) {
// Criteria changed → match no longer qualifies → remove stale record
await tablesDB.deleteRow(DATABASE_ID, TABLES.propertyMatches, existingId);
}
}
}
+2 -2
View File
@@ -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;
}
+7
View File
@@ -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;
}
+53
View File
@@ -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." };
}
}
+21
View File
@@ -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 [];
}
}
+201
View File
@@ -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;
}
+7
View File
@@ -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>;