Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f043f4acd7 | |||
| d9aff26376 | |||
| a40e68254b |
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "İşletmem — Giriş",
|
title: "Emlak CRM — Giriş",
|
||||||
description: "İşletmem KovakCRM hesabınıza giriş yapın veya yeni hesap oluşturun.",
|
description: "Emlak CRM hesabınıza giriş yapın.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useActionState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Logo } from "@/components/logo";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { signInAction } from "@/lib/appwrite/auth-actions";
|
import { signInAction } from "@/lib/appwrite/auth-actions";
|
||||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||||||
@@ -21,148 +19,183 @@ export function LoginForm1({
|
|||||||
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
|
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex min-h-svh w-full", className)} {...props}>
|
||||||
<Card className="overflow-hidden p-0">
|
{/* ── Sol: Marka paneli ── */}
|
||||||
<CardContent className="grid p-0 md:grid-cols-2">
|
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-slate-900 p-12 text-white lg:flex">
|
||||||
<form action={formAction} className="p-6 md:p-10">
|
{/* Arka plan dekorasyon */}
|
||||||
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
<div
|
||||||
<div className="flex flex-col gap-6">
|
className="pointer-events-none absolute inset-0"
|
||||||
<div className="flex justify-center">
|
style={{
|
||||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
backgroundImage:
|
||||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
"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%)",
|
||||||
<Logo size={22} />
|
}}
|
||||||
</div>
|
aria-hidden
|
||||||
<span className="text-xl font-semibold">İşletmem</span>
|
/>
|
||||||
</Link>
|
<div
|
||||||
</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
|
||||||
|
/>
|
||||||
|
|
||||||
{inviteCode && (
|
{/* Logo + Ürün adı */}
|
||||||
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
|
<div className="relative z-10 flex items-center gap-3">
|
||||||
Davete katılmak için giriş yapın.
|
<div className="flex size-10 items-center justify-center rounded-xl bg-blue-500/20 ring-1 ring-blue-400/30 backdrop-blur">
|
||||||
</p>
|
<Building2 className="size-5 text-blue-300" />
|
||||||
)}
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center">
|
{/* Orta içerik */}
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
<div className="relative z-10 space-y-8">
|
||||||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
<div className="space-y-3">
|
||||||
Hesabınıza giriş yaparak işletmenizi yönetmeye devam edin
|
<h2 className="text-3xl font-bold leading-snug tracking-tight">
|
||||||
</p>
|
Gayrimenkul süreçlerinizi tek platformdan yönetin
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<ul className="space-y-4">
|
||||||
<Label htmlFor="email">Email</Label>
|
{[
|
||||||
<Input
|
{
|
||||||
id="email"
|
icon: Building2,
|
||||||
name="email"
|
title: "Portföy yönetimi",
|
||||||
type="email"
|
desc: "Tüm ilanlarınızı fotoğraflarıyla ekleyin, takip edin",
|
||||||
placeholder="ornek@firma.com"
|
},
|
||||||
autoComplete="email"
|
{
|
||||||
required
|
icon: Zap,
|
||||||
/>
|
title: "Akıllı eşleşme",
|
||||||
</div>
|
desc: "Ağırlıklı puanlama ile müşteri × ilan eşleştirmesi",
|
||||||
|
},
|
||||||
<div className="grid gap-3">
|
{
|
||||||
<div className="flex items-center">
|
icon: Presentation,
|
||||||
<Label htmlFor="password">Şifre</Label>
|
title: "Sunum paylaşımı",
|
||||||
<Link
|
desc: "Müşteriye özel sunum linkleri oluşturun ve gönderin",
|
||||||
href="/forgot-password"
|
},
|
||||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
|
{
|
||||||
>
|
icon: Users,
|
||||||
Şifremi unuttum
|
title: "Müşteri & arama",
|
||||||
</Link>
|
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>
|
||||||
<Input
|
<div>
|
||||||
id="password"
|
<p className="text-sm font-medium leading-none">{title}</p>
|
||||||
name="password"
|
<p className="mt-0.5 text-xs text-slate-400">{desc}</p>
|
||||||
type="password"
|
</div>
|
||||||
autoComplete="current-password"
|
</li>
|
||||||
required
|
))}
|
||||||
/>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.error && (
|
{/* Alt: Kovak Yazılım */}
|
||||||
<p className="text-destructive text-sm text-center" role="alert">
|
<div className="relative z-10 flex flex-col gap-0.5">
|
||||||
{state.error}
|
<p className="text-xs font-semibold text-slate-300">Emlak CRM</p>
|
||||||
</p>
|
<p className="text-xs text-slate-500">Kovak Yazılım · kovaksoft.com</p>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
{/* ── Sağ: Giriş formu ── */}
|
||||||
{isPending ? (
|
<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">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
{/* Mobilde logo */}
|
||||||
Giriş yapılıyor...
|
<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" />
|
||||||
"Giriş yap"
|
</div>
|
||||||
)}
|
<span className="font-bold">Emlak CRM</span>
|
||||||
</Button>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="space-y-1.5">
|
||||||
Hesabınız yok mu?{" "}
|
<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="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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={formAction} className="space-y-5">
|
||||||
|
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ornek@firma.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password">Şifre</Label>
|
||||||
<Link
|
<Link
|
||||||
href="/sign-up"
|
href="/forgot-password"
|
||||||
className="text-foreground font-medium underline-offset-4 hover:underline"
|
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Hesap oluştur
|
Şifremi unuttum
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-destructive text-sm" role="alert">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Giriş yapılıyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Giriş yap"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<BrandPanel />
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
</CardContent>
|
Hesabınız yok mu?{" "}
|
||||||
</Card>
|
<Link
|
||||||
|
href="/sign-up"
|
||||||
<p className="text-muted-foreground text-center text-xs text-balance">
|
className="text-foreground font-medium underline-offset-4 hover:underline"
|
||||||
Giriş yaparak{" "}
|
>
|
||||||
<Link href="#" className="underline-offset-4 hover:underline">
|
Hesap oluştur
|
||||||
Kullanım Şartları
|
</Link>
|
||||||
</Link>{" "}
|
</p>
|
||||||
ve{" "}
|
|
||||||
<Link href="#" className="underline-offset-4 hover:underline">
|
|
||||||
Gizlilik Politikası
|
|
||||||
</Link>
|
|
||||||
'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>
|
</div>
|
||||||
<span className="text-lg font-medium">İşletmem</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col gap-3">
|
{/* Alt logo — sadece geniş ekranda gizli olan mobil için */}
|
||||||
<h2 className="text-3xl font-semibold leading-tight">
|
<p className="mt-10 text-xs text-muted-foreground lg:hidden">
|
||||||
Müşteriden faturaya, tek panelden işletmenizi yönetin.
|
Emlak CRM · Kovak Yazılım
|
||||||
</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.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,11 +12,5 @@ export default async function Page({
|
|||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
||||||
|
|
||||||
return (
|
return <LoginForm1 inviteCode={invite} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
<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 { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||||
import { listProperties } from "@/lib/appwrite/property-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 { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { MatchesClient } from "@/components/matches/matches-client";
|
||||||
|
|
||||||
export default async function MatchesPage() {
|
export default async function MatchesPage() {
|
||||||
const ctx = await requireTenant();
|
const ctx = await requireTenant();
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
const [customers, properties, matchesResult] = await Promise.all([
|
const [customers, properties, matchesResult, searchesResult] = await Promise.all([
|
||||||
listCustomers(ctx.tenantId),
|
listCustomers(ctx.tenantId),
|
||||||
listProperties(ctx.tenantId),
|
listProperties(ctx.tenantId),
|
||||||
tablesDB.listRows({
|
tablesDB.listRows({
|
||||||
@@ -24,58 +30,30 @@ export default async function MatchesPage() {
|
|||||||
Query.limit(500),
|
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 matches = (
|
||||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
JSON.parse(JSON.stringify(matchesResult.rows)) as PropertyMatch[]
|
||||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p.title]));
|
).sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||||
|
|
||||||
|
const searches = JSON.parse(JSON.stringify(searchesResult.rows)) as CustomerSearch[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<MatchesClient
|
||||||
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
|
matches={matches}
|
||||||
<span className="text-muted-foreground text-sm">{matches.length} eşleşme</span>
|
customers={customers}
|
||||||
</div>
|
properties={properties}
|
||||||
|
searches={searches}
|
||||||
<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>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
<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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
<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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
<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,
|
LISTING_TYPE_LABELS,
|
||||||
PROPERTY_STATUS_LABELS,
|
PROPERTY_STATUS_LABELS,
|
||||||
} from "@/lib/appwrite/schema";
|
} from "@/lib/appwrite/schema";
|
||||||
|
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
@@ -85,11 +86,22 @@ export default async function SunumPage({ params }: Props) {
|
|||||||
|
|
||||||
function PropertyCard({ property: p }: { property: Property }) {
|
function PropertyCard({ property: p }: { property: Property }) {
|
||||||
const isExpired = p.status === "satildi" || p.status === "kiralandit";
|
const isExpired = p.status === "satildi" || p.status === "kiralandit";
|
||||||
|
const imageIds = parseImageIds(p.imageIds);
|
||||||
|
const coverImageId = imageIds[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-xl border shadow-sm overflow-hidden ${isExpired ? "opacity-60" : ""}`}>
|
<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>
|
||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-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[]> };
|
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||||
const INITIAL: ActionState = { ok: false };
|
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 {
|
interface SearchFormSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
@@ -59,7 +83,6 @@ export function SearchFormSheet({
|
|||||||
|
|
||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
// Parse existing JSON array fields back to comma-separated strings for display
|
|
||||||
function parseJsonToInput(json?: string | null): string {
|
function parseJsonToInput(json?: string | null): string {
|
||||||
if (!json) return "";
|
if (!json) return "";
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +100,8 @@ export function SearchFormSheet({
|
|||||||
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
|
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
|
||||||
</SheetHeader>
|
</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">
|
<div className="grid gap-1.5">
|
||||||
<Label>Müşteri *</Label>
|
<Label>Müşteri *</Label>
|
||||||
<select
|
<select
|
||||||
@@ -95,6 +119,7 @@ export function SearchFormSheet({
|
|||||||
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* İlan türü */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>İlan türü</Label>
|
<Label>İlan türü</Label>
|
||||||
<select
|
<select
|
||||||
@@ -108,6 +133,7 @@ export function SearchFormSheet({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Emlak tipi + ağırlık */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="propertyTypes">Emlak tipleri</Label>
|
<Label htmlFor="propertyTypes">Emlak tipleri</Label>
|
||||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: daire, villa</p>
|
<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)}
|
defaultValue={parseJsonToInput(search?.propertyTypes)}
|
||||||
placeholder="daire, villa"
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Oda sayısı + ağırlık */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="roomCounts">Oda sayıları</Label>
|
<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>
|
<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)}
|
defaultValue={parseJsonToInput(search?.roomCounts)}
|
||||||
placeholder="2+1, 3+1"
|
placeholder="2+1, 3+1"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Önem:</span>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fiyat aralığı + ağırlık */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="cities">Şehirler</Label>
|
<Label>Fiyat aralığı</Label>
|
||||||
<Input
|
<div className="grid grid-cols-2 gap-3">
|
||||||
id="cities"
|
<div className="grid gap-1">
|
||||||
name="cities"
|
<span className="text-muted-foreground text-xs">Min</span>
|
||||||
defaultValue={parseJsonToInput(search?.cities)}
|
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
||||||
placeholder="İstanbul, Ankara"
|
</div>
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* M2 aralığı + ağırlık */}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="districts">İlçeler</Label>
|
<Label>m² aralığı</Label>
|
||||||
<Input
|
<div className="grid grid-cols-2 gap-3">
|
||||||
id="districts"
|
<div className="grid gap-1">
|
||||||
name="districts"
|
<span className="text-muted-foreground text-xs">Min</span>
|
||||||
defaultValue={parseJsonToInput(search?.districts)}
|
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
||||||
placeholder="Kadıköy, Beşiktaş"
|
</div>
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Konum + ağırlık */}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Konum</Label>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs">Şehirler (virgülle ayırın)</span>
|
||||||
|
<Input
|
||||||
|
name="cities"
|
||||||
|
defaultValue={parseJsonToInput(search?.cities)}
|
||||||
|
placeholder="İstanbul, Ankara"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 mt-1">
|
||||||
|
<span className="text-muted-foreground text-xs">İlçeler (virgülle ayırın)</span>
|
||||||
|
<Input
|
||||||
|
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">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="notes">Notlar</Label>
|
<Label htmlFor="notes">Notlar</Label>
|
||||||
<Textarea id="notes" name="notes" rows={2} defaultValue={search?.notes ?? ""} />
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -108,6 +109,12 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/properties/${p.$id}`}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Detay
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => openEdit(p)}>
|
<DropdownMenuItem onClick={() => openEdit(p)}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<Pencil className="mr-2 size-4" />
|
||||||
Düzenle
|
Düzenle
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { createPropertyAction, updatePropertyAction } from "@/lib/appwrite/property-actions";
|
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";
|
import type { Property } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
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ı..." />
|
<Textarea id="description" name="description" rows={3} defaultValue={property?.description ?? ""} placeholder="İlan detayları..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Fotoğraflar</Label>
|
||||||
|
<PropertyImageUploader
|
||||||
|
name="imageIds"
|
||||||
|
initialImageIds={parseImageIds(property?.imageIds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
{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,
|
tableId: TABLES.customers,
|
||||||
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(500)],
|
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> {
|
export async function getCustomer(id: string, tenantId: string): Promise<Customer | null> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.customers, id)) as unknown as Customer;
|
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.customers, id)) as unknown as Customer;
|
||||||
if (row.tenantId !== tenantId) return null;
|
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),
|
districts: toJsonList(data.districts),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
|
priceWeight: data.priceWeight,
|
||||||
|
m2Weight: data.m2Weight,
|
||||||
|
locationWeight: data.locationWeight,
|
||||||
|
roomCountWeight: data.roomCountWeight,
|
||||||
|
propertyTypeWeight: data.propertyTypeWeight,
|
||||||
createdBy: ctx.user.id,
|
createdBy: ctx.user.id,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -117,6 +122,11 @@ export async function updateCustomerSearchAction(
|
|||||||
cities: toJsonList(data.cities),
|
cities: toJsonList(data.cities),
|
||||||
districts: toJsonList(data.districts),
|
districts: toJsonList(data.districts),
|
||||||
notes: data.notes,
|
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);
|
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 { DATABASE_ID, TABLES, type Property, type CustomerSearch } from "./schema";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
|
import { scoreMatch } from "@/lib/scoring";
|
||||||
|
|
||||||
function priceMatches(price: number, min?: number | null, max?: number | null): boolean {
|
export { scoreMatch };
|
||||||
if (min != null && price < min) return false;
|
|
||||||
if (max != null && price > max) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function m2Matches(m2?: number | null, min?: number | null, max?: number | null): boolean {
|
const SCORE_THRESHOLD = 20;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function matchPropertyToSearches(
|
export async function matchPropertyToSearches(
|
||||||
property: Property,
|
property: Property,
|
||||||
@@ -74,15 +32,10 @@ export async function matchPropertyToSearches(
|
|||||||
const searches = searchesResult.rows as unknown as CustomerSearch[];
|
const searches = searchesResult.rows as unknown as CustomerSearch[];
|
||||||
|
|
||||||
for (const search of searches) {
|
for (const search of searches) {
|
||||||
if (search.listingType && search.listingType !== property.listingType) continue;
|
const listingTypeMismatch =
|
||||||
if (!listMatches(property.propertyType, search.propertyTypes)) continue;
|
!!search.listingType && search.listingType !== property.listingType;
|
||||||
if (property.roomCount && !listMatches(property.roomCount, search.roomCounts)) continue;
|
const score = listingTypeMismatch ? 0 : scoreMatch(property, search);
|
||||||
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 existing = await tablesDB.listRows({
|
const existing = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.propertyMatches,
|
tableId: TABLES.propertyMatches,
|
||||||
@@ -92,26 +45,37 @@ export async function matchPropertyToSearches(
|
|||||||
Query.limit(1),
|
Query.limit(1),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (existing.rows.length > 0) continue;
|
|
||||||
|
|
||||||
await tablesDB.createRow(
|
const existingId = existing.rows.length > 0 ? existing.rows[0].$id : null;
|
||||||
DATABASE_ID,
|
|
||||||
TABLES.propertyMatches,
|
if (score >= SCORE_THRESHOLD) {
|
||||||
ID.unique(),
|
if (existingId) {
|
||||||
{
|
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existingId, { score });
|
||||||
tenantId,
|
} else {
|
||||||
propertyId: property.$id,
|
await tablesDB.createRow(
|
||||||
customerId: search.customerId,
|
DATABASE_ID,
|
||||||
searchId: search.$id,
|
TABLES.propertyMatches,
|
||||||
notified: false,
|
ID.unique(),
|
||||||
createdBy,
|
{
|
||||||
},
|
tenantId,
|
||||||
[
|
propertyId: property.$id,
|
||||||
Permission.read(Role.team(tenantId)),
|
customerId: search.customerId,
|
||||||
Permission.update(Role.team(tenantId)),
|
searchId: search.$id,
|
||||||
Permission.delete(Role.team(tenantId, "owner")),
|
notified: false,
|
||||||
Permission.delete(Role.team(tenantId, "admin")),
|
score,
|
||||||
],
|
createdBy,
|
||||||
);
|
},
|
||||||
|
[
|
||||||
|
Permission.read(Role.team(tenantId)),
|
||||||
|
Permission.update(Role.team(tenantId)),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "admin")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (existingId) {
|
||||||
|
// Criteria changed → match no longer qualifies → remove stale record
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.propertyMatches, existingId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export async function listProperties(tenantId: string): Promise<Property[]> {
|
|||||||
tableId: TABLES.properties,
|
tableId: TABLES.properties,
|
||||||
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(200)],
|
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> {
|
export async function getProperty(id: string, tenantId: string): Promise<Property | null> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.properties, id)) as unknown as Property;
|
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.properties, id)) as unknown as Property;
|
||||||
if (row.tenantId !== tenantId) return null;
|
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;
|
isActive?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdBy: 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 {
|
export interface PropertyMatch extends Row {
|
||||||
@@ -114,6 +120,7 @@ export interface PropertyMatch extends Row {
|
|||||||
searchId: string;
|
searchId: string;
|
||||||
notified?: boolean;
|
notified?: boolean;
|
||||||
viewedAt?: string;
|
viewedAt?: string;
|
||||||
|
score?: number;
|
||||||
createdBy: string;
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const weightField = z.coerce.number().int().min(1).max(5).optional().nullable();
|
||||||
|
|
||||||
export const customerSearchSchema = z.object({
|
export const customerSearchSchema = z.object({
|
||||||
customerId: z.string().min(1, "Müşteri seçin"),
|
customerId: z.string().min(1, "Müşteri seçin"),
|
||||||
listingType: z.enum(["satilik", "kiralik"]).optional().or(z.literal("")),
|
listingType: z.enum(["satilik", "kiralik"]).optional().or(z.literal("")),
|
||||||
@@ -13,6 +15,11 @@ export const customerSearchSchema = z.object({
|
|||||||
districts: z.string().max(1000).optional(),
|
districts: z.string().max(1000).optional(),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
notes: z.string().max(5000).optional(),
|
notes: z.string().max(5000).optional(),
|
||||||
|
priceWeight: weightField,
|
||||||
|
m2Weight: weightField,
|
||||||
|
locationWeight: weightField,
|
||||||
|
roomCountWeight: weightField,
|
||||||
|
propertyTypeWeight: weightField,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CustomerSearchFormValues = z.infer<typeof customerSearchSchema>;
|
export type CustomerSearchFormValues = z.infer<typeof customerSearchSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user