feat: weighted match scoring, photo upload, property detail page

- scoring.ts: pure scoreMatch + scoreMatchBreakdown with per-criterion weights
- matching.ts: soft scoring (0-100), updates score on re-sync, threshold 20
- search-form-sheet: weight selectors (1-5) per criterion
- customer-search-actions: save/update weight fields
- storage-actions: upload/delete property images to property-images bucket
- storage-utils: getPropertyImagePreviewUrl, parseImageIds helpers
- property-image-uploader: client component with preview grid + delete
- property-form-sheet: integrated image uploader
- properties/[id]: detail page with gallery, specs, matches sidebar
- properties-client: Detay link in dropdown
- matches page: MatchesClient with click-to-breakdown dialog
- sunum page: cover image from first imageIds entry
- matches-client + match-breakdown-dialog: score breakdown per criterion
This commit is contained in:
egecankomur
2026-05-05 19:55:34 +03:00
parent 3d044c5d5b
commit a40e68254b
22 changed files with 1105 additions and 169 deletions
+94 -37
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>
<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>
<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 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 htmlFor="cities">Şehirler</Label>
<Input
id="cities"
name="cities"
defaultValue={parseJsonToInput(search?.cities)}
placeholder="İstanbul, Ankara"
/>
<Label>Fiyat aralığı</Label>
<div className="grid grid-cols-2 gap-3">
<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">
<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 htmlFor="districts">İlçeler</Label>
<Input
id="districts"
name="districts"
defaultValue={parseJsonToInput(search?.districts)}
placeholder="Kadıköy, Beşiktaş"
/>
<Label>m² aralığı</Label>
<div className="grid grid-cols-2 gap-3">
<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">
<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>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">
<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>
);
}