perf: memoize parseImageIds, fix checkLimit OR query, loading skeletons, dashboard cache, compound indexes, sidebar active state, matches notified fix, padding fixes, match criteria in property detail
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BellRinging, Checks } from '@/lib/icons';
|
||||
import Link from "next/link";
|
||||
import { BellRinging, Checks, Phone, Envelope } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -46,8 +47,8 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||
|
||||
const pendingCount = items.filter((m) => !m.notified).length;
|
||||
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
|
||||
const pendingCount = items.filter((m) => m.notified !== true).length;
|
||||
const visible = tab === "pending" ? items.filter((m) => m.notified !== true) : items;
|
||||
|
||||
function openBreakdown(m: PropertyMatch) {
|
||||
setSelectedMatch(m);
|
||||
@@ -157,7 +158,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
||||
<tr
|
||||
key={m.$id}
|
||||
className={`border-b last:border-0 ${
|
||||
!m.notified ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
|
||||
m.notified !== true ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
@@ -165,21 +166,51 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
||||
</td>
|
||||
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
|
||||
<p className="font-medium">{customer?.name ?? m.customerId}</p>
|
||||
{customer?.phone && (
|
||||
<p className="text-xs text-muted-foreground">{customer.phone}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5 mt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
{customer?.phone && (
|
||||
<a
|
||||
href={`tel:${customer.phone}`}
|
||||
className="flex items-center gap-1 text-xs text-foreground hover:text-primary transition-colors w-fit"
|
||||
>
|
||||
<Phone className="size-3 shrink-0" />
|
||||
{customer.phone}
|
||||
</a>
|
||||
)}
|
||||
{customer?.email && (
|
||||
<a
|
||||
href={`mailto:${customer.email}`}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors w-fit"
|
||||
>
|
||||
<Envelope className="size-3 shrink-0" />
|
||||
{customer.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
|
||||
<p className="truncate">{property?.title ?? m.propertyId}</p>
|
||||
<td className="p-3 max-w-[200px]">
|
||||
<Link
|
||||
href={`/properties/${m.propertyId}`}
|
||||
className="font-medium hover:text-primary hover:underline transition-colors truncate block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{property?.title ?? m.propertyId}
|
||||
</Link>
|
||||
{property?.city && (
|
||||
<p className="text-xs text-muted-foreground">{property.city}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{property.city}{property.district ? `, ${property.district}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{property?.price != null && (
|
||||
<p className="text-xs font-medium text-foreground/80">
|
||||
{property.price.toLocaleString("tr-TR")} {property.currency ?? "₺"}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground hidden md:table-cell">
|
||||
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{m.notified ? (
|
||||
{m.notified === true ? (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Checks className="size-3.5 text-green-500" />
|
||||
Bildirildi
|
||||
@@ -192,7 +223,7 @@ export function MatchesClient({ matches, customers, properties, searches }: Matc
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{!m.notified && (
|
||||
{m.notified !== true && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
+52
-36
@@ -40,10 +40,23 @@ export function NavMain({
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Check if any subitem is active to determine if parent should be open
|
||||
// Returns the url of the best-matching subitem (longest prefix wins).
|
||||
// Handles detail pages: /customers/123 correctly activates the /customers subitem
|
||||
// without accidentally activating /customers/searches (a sibling).
|
||||
function bestSubMatch(subitems: { url: string }[]): string | null {
|
||||
let best: string | null = null
|
||||
for (const sub of subitems) {
|
||||
if (pathname === sub.url || pathname.startsWith(sub.url + "/")) {
|
||||
if (!best || sub.url.length > best.length) best = sub.url
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const shouldBeOpen = (item: typeof items[0]) => {
|
||||
if (item.isActive) return true
|
||||
return item.items?.some(subItem => pathname === subItem.url) || false
|
||||
if (item.items) return bestSubMatch(item.items) !== null
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,40 +71,43 @@ export function NavMain({
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
{item.items?.length ? (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={pathname === subItem.url}>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge != null && subItem.badge > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||
{subItem.badge > 99 ? "99+" : subItem.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : (
|
||||
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url}>
|
||||
{item.items?.length ? (() => {
|
||||
const activeSubUrl = bestSubMatch(item.items)
|
||||
return (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
|
||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge != null && subItem.badge > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||
{subItem.badge > 99 ? "99+" : subItem.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
)
|
||||
})() : (
|
||||
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
|
||||
@@ -103,10 +103,15 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
|
||||
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
|
||||
|
||||
const parsedImageIds = useMemo(() =>
|
||||
Object.fromEntries(properties.map((p) => [p.$id, parseImageIds(p.imageIds)])),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const { withImages, withoutImages } = useMemo(() => ({
|
||||
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
|
||||
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
|
||||
}), [filteredProperties]);
|
||||
withImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) > 0),
|
||||
withoutImages: filteredProperties.filter((p) => (parsedImageIds[p.$id]?.length ?? 0) === 0),
|
||||
}), [filteredProperties, parsedImageIds]);
|
||||
|
||||
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
|
||||
{ key: "all", label: "Tümü" },
|
||||
@@ -220,13 +225,13 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
{withoutImages.length > 0 && (
|
||||
<ImageGroupHeader hasImages count={withImages.length} />
|
||||
)}
|
||||
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||
</>
|
||||
)}
|
||||
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
|
||||
)}
|
||||
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
@@ -260,7 +265,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
</TableRow>
|
||||
)}
|
||||
{withImages.map((p) => (
|
||||
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||
))}
|
||||
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -270,7 +275,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
</TableRow>
|
||||
)}
|
||||
{withoutImages.map((p) => (
|
||||
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||
<PropertyTableRow key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -289,7 +294,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
{withoutImages.length > 0 && <ImageGroupHeader hasImages count={withImages.length} />}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||
{withImages.map((p) => (
|
||||
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -302,7 +307,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
{withoutImages.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||
{withoutImages.map((p) => (
|
||||
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||
<GalleryCard key={p.$id} p={p} imageIds={parsedImageIds[p.$id] ?? []} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -321,7 +326,7 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
|
||||
)}
|
||||
{filteredProperties.map((p) => {
|
||||
const coverImageId = parseImageIds(p.imageIds)[0];
|
||||
const coverImageId = (parsedImageIds[p.$id] ?? [])[0];
|
||||
const hasCoords = p.mapLat != null && p.mapLng != null;
|
||||
const isSelected = selectedId === p.$id;
|
||||
return (
|
||||
@@ -419,12 +424,12 @@ export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxIm
|
||||
}
|
||||
|
||||
/* ── Galeri kartı ── */
|
||||
function GalleryCard({ p, openEdit, setDeleteTarget }: {
|
||||
function GalleryCard({ p, imageIds, openEdit, setDeleteTarget }: {
|
||||
p: Property;
|
||||
imageIds: string[];
|
||||
openEdit: (p: Property) => void;
|
||||
setDeleteTarget: (p: Property) => void;
|
||||
}) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const safeIdx = Math.min(idx, imageIds.length - 1);
|
||||
@@ -550,12 +555,13 @@ function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: num
|
||||
}
|
||||
|
||||
/* ── Mobil kart ── */
|
||||
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
|
||||
function MobilePropertyCard({ p, imageIds, openEdit, setDeleteTarget }: {
|
||||
p: Property;
|
||||
imageIds: string[];
|
||||
openEdit: (p: Property) => void;
|
||||
setDeleteTarget: (p: Property) => void;
|
||||
}) {
|
||||
const coverImageId = parseImageIds(p.imageIds)[0];
|
||||
const coverImageId = imageIds[0];
|
||||
return (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{coverImageId && (
|
||||
@@ -611,14 +617,14 @@ function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
|
||||
}
|
||||
|
||||
/* ── Tablo satırı ── */
|
||||
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
|
||||
function PropertyTableRow({ p, imageIds, rowRefs, openEdit, setDeleteTarget, router }: {
|
||||
p: Property;
|
||||
imageIds: string[];
|
||||
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
|
||||
openEdit: (p: Property) => void;
|
||||
setDeleteTarget: (p: Property) => void;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
}) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const coverImageId = imageIds[0];
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X } from '@/lib/icons';
|
||||
import { ArrowLeft, PencilSimple, MapPin, ImageSquare, Plus, FileText, ClipboardText, Users, CaretLeft, CaretRight, X, Phone, Envelope, BellRinging, Checks } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
type Property,
|
||||
type PropertyMatch,
|
||||
type CustomerSearch,
|
||||
type Customer,
|
||||
type Activity,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
|
||||
@@ -28,7 +30,8 @@ interface Props {
|
||||
matches: PropertyMatch[];
|
||||
activities: Activity[];
|
||||
imageIds: string[];
|
||||
customerMap: Record<string, string>;
|
||||
customers: Customer[];
|
||||
searches: CustomerSearch[];
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -39,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
rezerve: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
|
||||
};
|
||||
|
||||
export function PropertyDetailClient({ property, matches, activities, imageIds, customerMap }: Props) {
|
||||
export function PropertyDetailClient({ property, matches, activities, imageIds, customers, searches }: Props) {
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
|
||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [imageOpen, setImageOpen] = useState(false);
|
||||
@@ -219,15 +224,19 @@ export function PropertyDetailClient({ property, matches, activities, imageIds,
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Henüz 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 border-b pb-2 last:border-0 last:pb-0">
|
||||
<span className="text-sm truncate">
|
||||
{customerMap[m.customerId] ?? m.customerId}
|
||||
</span>
|
||||
<ScoreBadge score={m.score} />
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3">
|
||||
{matches.map((m) => {
|
||||
const customer = customerMap[m.customerId];
|
||||
const search = searchMap[m.searchId];
|
||||
return (
|
||||
<MatchCard
|
||||
key={m.$id}
|
||||
match={m}
|
||||
customer={customer}
|
||||
search={search}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -375,6 +384,109 @@ function Spec({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
|
||||
function MatchCard({ match, customer, search }: {
|
||||
match: PropertyMatch;
|
||||
customer?: Customer;
|
||||
search?: CustomerSearch;
|
||||
}) {
|
||||
const fmt = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
const criteria: string[] = [];
|
||||
if (search?.listingType) criteria.push(LISTING_TYPE_LABELS[search.listingType] ?? search.listingType);
|
||||
if (search?.propertyTypes) {
|
||||
try {
|
||||
const types = JSON.parse(search.propertyTypes) as string[];
|
||||
types.forEach((t) => criteria.push(PROPERTY_TYPE_LABELS[t as keyof typeof PROPERTY_TYPE_LABELS] ?? t));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (search?.roomCounts) {
|
||||
try {
|
||||
const rooms = JSON.parse(search.roomCounts) as string[];
|
||||
if (rooms.length) criteria.push(`${rooms.join(", ")} oda`);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (search?.minPrice != null || search?.maxPrice != null) {
|
||||
const min = search.minPrice != null ? `${fmt(search.minPrice)} ₺` : null;
|
||||
const max = search.maxPrice != null ? `${fmt(search.maxPrice)} ₺` : null;
|
||||
criteria.push(min && max ? `${min} – ${max}` : min ? `min ${min}` : `max ${max!}`);
|
||||
}
|
||||
if (search?.minM2 != null || search?.maxM2 != null) {
|
||||
const min = search.minM2 != null ? `${search.minM2} m²` : null;
|
||||
const max = search.maxM2 != null ? `${search.maxM2} m²` : null;
|
||||
criteria.push(min && max ? `${min} – ${max}` : min ? `min ${min}` : `max ${max!}`);
|
||||
}
|
||||
if (search?.cities) {
|
||||
try {
|
||||
const cities = JSON.parse(search.cities) as string[];
|
||||
if (cities.length) criteria.push(cities.join(", "));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (search?.districts) {
|
||||
try {
|
||||
const districts = JSON.parse(search.districts) as string[];
|
||||
if (districts.length) criteria.push(districts.join(", "));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 space-y-2.5 ${match.notified !== true ? "bg-amber-50/30 dark:bg-amber-950/10 border-amber-200/50 dark:border-amber-800/30" : ""}`}>
|
||||
{/* Üst satır: isim + puan + bildirim durumu */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/customers/${match.customerId}`}
|
||||
className="font-medium text-sm hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{customer?.name ?? match.customerId}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||
{customer?.phone && (
|
||||
<a href={`tel:${customer.phone}`} className="flex items-center gap-1 text-xs text-foreground/80 hover:text-primary transition-colors">
|
||||
<Phone className="size-3 shrink-0" />
|
||||
{customer.phone}
|
||||
</a>
|
||||
)}
|
||||
{customer?.email && (
|
||||
<a href={`mailto:${customer.email}`} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
<Envelope className="size-3 shrink-0" />
|
||||
{customer.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{match.notified === true ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Checks className="size-3.5 text-green-500" />
|
||||
Bildirildi
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||
<BellRinging className="size-3.5" />
|
||||
Bekliyor
|
||||
</span>
|
||||
)}
|
||||
<ScoreBadge score={match.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arama kriterleri */}
|
||||
{criteria.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-dashed">
|
||||
{criteria.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{search?.notes && (
|
||||
<p className="text-xs text-muted-foreground italic border-t pt-1.5">{search.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score?: number | null }) {
|
||||
const s = score ?? 0;
|
||||
const color =
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
} from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyImageUploader } from "./property-image-uploader";
|
||||
import { updatePropertyImagesAction } from "@/lib/appwrite/property-actions";
|
||||
import {
|
||||
updatePropertyImagesAction,
|
||||
} from "@/lib/appwrite/property-actions";
|
||||
import {
|
||||
deletePropertyImageAndSyncAction,
|
||||
} from "@/lib/appwrite/storage-actions";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
interface PropertyImageSheetProps {
|
||||
@@ -39,6 +44,20 @@ export function PropertyImageSheet({
|
||||
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Silme: storage + DB'yi tek seferde güncelle, "Kaydet" gerektirmez
|
||||
async function handleDeleteImage(fileId: string): Promise<boolean> {
|
||||
const remaining = imageIds.filter((id) => id !== fileId);
|
||||
const result = await deletePropertyImageAndSyncAction(propertyId, fileId, remaining);
|
||||
if (result.ok) {
|
||||
setImageIds(remaining);
|
||||
onSuccess?.();
|
||||
return true;
|
||||
}
|
||||
toast.error(result.error ?? "Fotoğraf silinemedi.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Yeni yüklenen fotoğrafları kaydet
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const result = await updatePropertyImagesAction(propertyId, imageIds);
|
||||
@@ -56,12 +75,13 @@ export function PropertyImageSheet({
|
||||
<div className="space-y-4">
|
||||
<PropertyImageUploader
|
||||
name="imageIds"
|
||||
initialImageIds={initialImageIds}
|
||||
initialImageIds={imageIds}
|
||||
onChangeIds={setImageIds}
|
||||
onDeleteImage={handleDeleteImage}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
İptal
|
||||
Kapat
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Kaydediliyor…" : "Kaydet"}
|
||||
|
||||
@@ -15,11 +15,12 @@ interface PropertyImageUploaderProps {
|
||||
name: string;
|
||||
initialImageIds?: string[];
|
||||
onChangeIds?: (ids: string[]) => void;
|
||||
onDeleteImage?: (fileId: string) => Promise<boolean>;
|
||||
maxImages?: number;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) {
|
||||
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, onDeleteImage, maxImages, isOwner }: PropertyImageUploaderProps) {
|
||||
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
|
||||
const [queue, setQueue] = useState<UploadingFile[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -119,6 +120,11 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
if (onDeleteImage) {
|
||||
const ok = await onDeleteImage(fileId);
|
||||
if (ok) updateIds((prev) => prev.filter((id) => id !== fileId));
|
||||
return;
|
||||
}
|
||||
const result = await deletePropertyImageAction(fileId);
|
||||
if (result.ok) {
|
||||
updateIds((prev) => prev.filter((id) => id !== fileId));
|
||||
@@ -195,7 +201,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
||||
{imageIds.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{imageIds.map((id) => (
|
||||
<div key={id} className="group relative aspect-video overflow-hidden rounded-md border bg-muted">
|
||||
<div key={id} className="relative aspect-video overflow-hidden rounded-md border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(id, 400, 300)}
|
||||
@@ -206,7 +212,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds,
|
||||
type="button"
|
||||
onClick={() => handleDelete(id)}
|
||||
disabled={busy}
|
||||
className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-red-500 group-hover:flex"
|
||||
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-red-500"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user