feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap

This commit is contained in:
egecankomur
2026-05-12 04:49:36 +03:00
parent 3cce632eb3
commit 3554b39800
134 changed files with 7736 additions and 1913 deletions
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect } from "react";
import { CheckCircle, Circle } from '@/lib/icons';
import { cn } from "@/lib/utils";
import { ACADEMY_MODULES } from "@/lib/academy/tours";
import { getCompletedModules, resetProgress } from "@/lib/academy/progress";
import { AcademyTourButton } from "./academy-tour-button";
import { Button } from "@/components/ui/button";
export function AcademyClient() {
const [completed, setCompleted] = useState<string[]>([]);
useEffect(() => {
setCompleted(getCompletedModules());
}, []);
function handleComplete() {
setCompleted(getCompletedModules());
}
function handleReset() {
resetProgress();
setCompleted([]);
}
const percent = Math.round((completed.length / ACADEMY_MODULES.length) * 100);
const allDone = completed.length === ACADEMY_MODULES.length;
return (
<div className="space-y-6">
{/* Progress header */}
<div className="bg-card border rounded-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-sm">Genel İlerleme</p>
<p className="text-muted-foreground text-xs mt-0.5">
{completed.length} / {ACADEMY_MODULES.length} modül tamamlandı
</p>
</div>
<span className={cn(
"text-2xl font-bold",
allDone ? "text-green-600" : "text-primary"
)}>
%{percent}
</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
allDone ? "bg-green-500" : "bg-primary"
)}
style={{ width: `${percent}%` }}
/>
</div>
{allDone && (
<p className="text-green-600 text-sm font-medium">
🎉 Tüm modülleri tamamladınız! Artık KovakEmlak CRM&apos;i tam verimle kullanabilirsiniz.
</p>
)}
</div>
{/* Module grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ACADEMY_MODULES.map((mod) => {
const isDone = completed.includes(mod.id);
return (
<div
key={mod.id}
className={cn(
"bg-card border rounded-xl p-5 flex flex-col gap-3 transition-colors",
isDone && "border-green-200 bg-green-50/40 dark:bg-green-950/20 dark:border-green-900"
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span className="text-2xl">{mod.icon}</span>
<div>
<p className="font-semibold text-sm leading-tight">{mod.title}</p>
<p className="text-muted-foreground text-xs mt-0.5">
{mod.steps.length} adım
</p>
</div>
</div>
{isDone ? (
<CheckCircle className="size-5 text-green-500 shrink-0 mt-0.5" />
) : (
<Circle className="size-5 text-muted-foreground/40 shrink-0 mt-0.5" />
)}
</div>
<p className="text-muted-foreground text-xs leading-relaxed flex-1">
{mod.description}
</p>
{/* Step previews */}
<div className="space-y-1">
{mod.steps.slice(0, 3).map((step, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"size-4 rounded-full flex items-center justify-center text-[10px] font-medium shrink-0",
isDone
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "bg-muted text-muted-foreground"
)}>
{i + 1}
</span>
<span className="truncate">{step.title}</span>
</div>
))}
</div>
<AcademyTourButton
module={mod}
onComplete={handleComplete}
variant={isDone ? "ghost" : "outline"}
/>
</div>
);
})}
</div>
{completed.length > 0 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={handleReset} className="text-muted-foreground text-xs">
İlerlemeyi sıfırla
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { GraduationCap } from '@/lib/icons';
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { ACADEMY_MODULES } from "@/lib/academy/tours";
import { getCompletedModules } from "@/lib/academy/progress";
export function AcademySidebarBadge() {
const [percent, setPercent] = useState(0);
const pathname = usePathname();
const isActive = pathname === "/academy";
useEffect(() => {
function update() {
const completed = getCompletedModules();
setPercent(Math.round((completed.length / ACADEMY_MODULES.length) * 100));
}
update();
window.addEventListener("focus", update);
return () => window.removeEventListener("focus", update);
}, []);
if (percent === 100) return null;
return (
<Link
href="/academy"
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors mx-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<GraduationCap className="size-4 shrink-0" />
<span className="flex-1 text-xs font-medium">Akademi</span>
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
percent === 0
? "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"
: "bg-primary/15 text-primary"
)}>
%{percent}
</span>
</Link>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useRouter } from "next/navigation";
import { PlayCircle } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import type { AcademyModule } from "@/lib/academy/tours";
import { markModuleComplete } from "@/lib/academy/progress";
interface AcademyTourButtonProps {
module: AcademyModule;
onComplete?: () => void;
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm";
}
export function AcademyTourButton({
module,
onComplete,
variant = "outline",
size = "sm",
}: AcademyTourButtonProps) {
const router = useRouter();
async function startTour() {
// @ts-expect-error - CSS loaded dynamically
await import("driver.js/dist/driver.css");
const { driver } = await import("driver.js");
router.push(module.url);
await new Promise((r) => setTimeout(r, 900));
let driverObj: ReturnType<typeof driver>;
driverObj = driver({
showProgress: true,
progressText: "{{current}} / {{total}}",
nextBtnText: "İleri →",
prevBtnText: "← Geri",
doneBtnText: "Tamamla ✓",
smoothScroll: true,
onDestroyStarted: () => {
driverObj.destroy();
},
onDestroyed: () => {
// Close any open form
if (module.closeEvent) {
window.dispatchEvent(new CustomEvent(module.closeEvent));
}
markModuleComplete(module.id);
onComplete?.();
},
onNextClick: (_el, _step, { state }) => {
const nextIndex = (state.activeIndex ?? 0) + 1;
if (nextIndex >= module.steps.length) {
driverObj.destroy();
return;
}
const nextStep = module.steps[nextIndex];
// Dispatch a window event before this step (e.g. open a form)
if (nextStep.triggerEvent) {
window.dispatchEvent(new CustomEvent(nextStep.triggerEvent));
setTimeout(() => driverObj.moveNext(), nextStep.triggerDelay ?? 700);
return;
}
// Click a DOM element before this step (e.g. navigate form wizard)
if (nextStep.clickBefore) {
const target = document.querySelector<HTMLElement>(nextStep.clickBefore);
target?.click();
setTimeout(() => driverObj.moveNext(), nextStep.clickDelay ?? 350);
return;
}
driverObj.moveNext();
},
steps: module.steps.map((s) => ({
element: s.element,
popover: {
title: s.title,
description: s.description,
side: s.side ?? "bottom",
align: "start",
},
})),
});
driverObj.drive();
}
return (
<Button variant={variant} size={size} onClick={startTour} className="gap-1.5">
<PlayCircle className="size-4" />
Turu Başlat
</Button>
);
}
+88 -24
View File
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, List, CalendarDots } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -17,9 +18,13 @@ import {
deleteActivityAction,
} from "@/lib/appwrite/activity-actions";
import { ActivityFormSheet } from "./activity-form-sheet";
import { ActivityCalendar } from "./activity-calendar";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
type ViewMode = "list" | "calendar";
interface ActivitiesClientProps {
initialActivities: Activity[];
customers: Customer[];
@@ -31,9 +36,12 @@ export function ActivitiesClient({
customers,
properties,
}: ActivitiesClientProps) {
const router = useRouter();
const [activities, setActivities] = useState(initialActivities);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Activity | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Activity | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("list");
function customerName(id?: string | null) {
if (!id) return "—";
@@ -45,15 +53,19 @@ export function ActivitiesClient({
return properties.find((p) => p.$id === id)?.title ?? "—";
}
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-activities", open);
window.addEventListener("kovak:close-form-activities", close);
return () => {
window.removeEventListener("kovak:open-form-activities", open);
window.removeEventListener("kovak:close-form-activities", close);
};
}, []);
function openEdit(a: Activity) {
setEditing(a);
setSheetOpen(true);
}
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(a: Activity) { setEditing(a); setSheetOpen(true); }
async function handleComplete(a: Activity) {
const result = await completeActivityAction(a.$id);
@@ -67,11 +79,12 @@ export function ActivitiesClient({
}
}
async function handleDelete(a: Activity) {
if (!confirm("Bu aktivite silinsin mi?")) return;
const result = await deleteActivityAction(a.$id);
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteActivityAction(deleteTarget.$id);
if (result.ok) {
setActivities((prev) => prev.filter((x) => x.$id !== a.$id));
setActivities((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("Aktivite silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -80,15 +93,57 @@ export function ActivitiesClient({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Aktiviteler</h1>
<Button onClick={openCreate} size="sm">
<Plus className="mr-1.5 size-4" />
Yeni Aktivite
</Button>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "list"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<List className="size-3.5" />
Liste
</button>
<button
type="button"
onClick={() => setViewMode("calendar")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "calendar"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<CalendarDots className="size-3.5" />
Takvim
</button>
</div>
<Button onClick={openCreate} size="sm" data-tour="activities-add">
<Plus className="mr-1.5 size-4" />
Yeni Aktivite
</Button>
</div>
</div>
<div className="rounded-md border">
{/* Calendar view */}
{viewMode === "calendar" && (
<ActivityCalendar
activities={activities}
customers={customers}
properties={properties}
onEdit={openEdit}
onComplete={handleComplete}
/>
)}
{/* List view */}
{viewMode === "list" && (
<div data-tour="activities-table" className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
@@ -133,7 +188,7 @@ export function ActivitiesClient({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -144,14 +199,14 @@ export function ActivitiesClient({
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openEdit(a)}>
<Pencil className="mr-2 size-4" />
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(a)}
onClick={() => setDeleteTarget(a)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
@@ -162,6 +217,7 @@ export function ActivitiesClient({
</TableBody>
</Table>
</div>
)}
<ActivityFormSheet
open={sheetOpen}
@@ -169,6 +225,14 @@ export function ActivitiesClient({
activity={editing}
customers={customers}
properties={properties}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title="Bu aktivite silinsin mi?"
description="Bu aktivite kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
@@ -0,0 +1,320 @@
"use client";
import { useState, useMemo } from "react";
import { CaretLeft, CaretRight, CheckCircle, PencilSimple, Clock, User, Buildings } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
const DAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
const MONTHS = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
];
const TYPE_COLORS: Record<string, string> = {
gorusme: "bg-blue-500",
teklif: "bg-amber-500",
ziyaret: "bg-emerald-500",
arama: "bg-purple-500",
not: "bg-gray-400",
};
const TYPE_BADGE_COLORS: Record<string, string> = {
gorusme: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
teklif: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
ziyaret: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
arama: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
not: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
};
function toDateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function activityDateKey(a: Activity): string | null {
if (!a.dueDate) return null;
const d = new Date(a.dueDate);
if (isNaN(d.getTime())) return null;
return toDateKey(d);
}
interface Props {
activities: Activity[];
customers: Customer[];
properties: Property[];
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
}
export function ActivityCalendar({ activities, customers, properties, onEdit, onComplete }: Props) {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [selectedKey, setSelectedKey] = useState<string | null>(toDateKey(today));
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
}
// Index activities by date key
const byDate = useMemo(() => {
const map: Record<string, Activity[]> = {};
for (const a of activities) {
const key = activityDateKey(a);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(a);
}
return map;
}, [activities]);
// Build calendar grid
const cells = useMemo(() => {
const firstDay = new Date(year, month, 1);
// Monday-based: 0=Mon … 6=Sun
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const totalCells = Math.ceil((startOffset + daysInMonth) / 7) * 7;
const result: Array<{ date: Date | null; key: string | null }> = [];
for (let i = 0; i < totalCells; i++) {
const dayNum = i - startOffset + 1;
if (dayNum < 1 || dayNum > daysInMonth) {
result.push({ date: null, key: null });
} else {
const d = new Date(year, month, dayNum);
result.push({ date: d, key: toDateKey(d) });
}
}
return result;
}, [year, month]);
const selectedActivities = selectedKey ? (byDate[selectedKey] ?? []) : [];
const selectedDate = selectedKey ? new Date(selectedKey + "T12:00:00") : null;
function customerName(id?: string | null) {
if (!id) return null;
return customers.find((c) => c.$id === id)?.name ?? null;
}
function propertyTitle(id?: string | null) {
if (!id) return null;
return properties.find((p) => p.$id === id)?.title ?? null;
}
const todayKey = toDateKey(today);
return (
<div className="flex flex-col lg:flex-row gap-4 min-h-0">
{/* === Calendar Grid === */}
<div className="flex-1 min-w-0">
{/* Month nav */}
<div className="flex items-center justify-between mb-3">
<Button variant="ghost" size="icon" onClick={prevMonth} className="size-8">
<CaretLeft className="size-4" />
</Button>
<span className="font-semibold text-base">
{MONTHS[month]} {year}
</span>
<Button variant="ghost" size="icon" onClick={nextMonth} className="size-8">
<CaretRight className="size-4" />
</Button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{DAYS.map((d) => (
<div key={d} className="text-center text-xs text-muted-foreground py-1 font-medium">
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
{cells.map((cell, i) => {
if (!cell.date || !cell.key) {
return <div key={i} className="bg-muted/40 min-h-[72px]" />;
}
const cellActivities = byDate[cell.key] ?? [];
const isToday = cell.key === todayKey;
const isSelected = cell.key === selectedKey;
const visible = cellActivities.slice(0, 3);
const overflow = cellActivities.length - 3;
return (
<button
key={cell.key}
type="button"
onClick={() => setSelectedKey(cell.key)}
className={`bg-background min-h-[72px] p-1.5 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isSelected ? "ring-2 ring-inset ring-primary" : ""
}`}
>
<span
className={`text-xs font-medium inline-flex size-5 items-center justify-center rounded-full mb-1 ${
isToday
? "bg-primary text-primary-foreground"
: "text-foreground"
}`}
>
{cell.date.getDate()}
</span>
<div className="flex flex-col gap-px">
{visible.map((a) => (
<div
key={a.$id}
className={`text-[10px] leading-4 rounded px-1 truncate text-white ${TYPE_COLORS[a.type] ?? "bg-gray-400"} ${
a.completedAt ? "opacity-50" : ""
}`}
>
{a.title}
</div>
))}
{overflow > 0 && (
<div className="text-[10px] text-muted-foreground pl-1">+{overflow} daha</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-3 text-xs text-muted-foreground">
{Object.entries(ACTIVITY_TYPE_LABELS).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className={`inline-block size-2 rounded-full ${TYPE_COLORS[key]}`} />
{label}
</div>
))}
</div>
</div>
{/* === Day Panel === */}
<div className="w-full lg:w-72 xl:w-80 shrink-0">
<div className="rounded-lg border h-full flex flex-col">
{/* Panel header */}
<div className="px-4 py-3 border-b">
{selectedDate ? (
<div>
<p className="font-semibold text-sm">
{selectedDate.toLocaleDateString("tr-TR", { weekday: "long", day: "numeric", month: "long" })}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{selectedActivities.length === 0
? "Bu gün için aktivite yok"
: `${selectedActivities.length} aktivite`}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">Gün seçin</p>
)}
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto">
{selectedActivities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
<Clock className="size-8 mb-2 opacity-30" />
<span>Aktivite bulunmuyor</span>
</div>
) : (
<div className="divide-y">
{selectedActivities.map((a) => (
<ActivityCard
key={a.$id}
activity={a}
customerName={customerName(a.customerId)}
propertyTitle={propertyTitle(a.propertyId)}
onEdit={onEdit}
onComplete={onComplete}
typeBadgeColor={TYPE_BADGE_COLORS[a.type] ?? TYPE_BADGE_COLORS.not}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
interface CardProps {
activity: Activity;
customerName: string | null;
propertyTitle: string | null;
typeBadgeColor: string;
onEdit: (a: Activity) => void;
onComplete: (a: Activity) => void;
}
function ActivityCard({ activity: a, customerName, propertyTitle, typeBadgeColor, onEdit, onComplete }: CardProps) {
return (
<div className={`px-4 py-3 hover:bg-muted/40 transition-colors ${a.completedAt ? "opacity-60" : ""}`}>
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className={`inline-flex text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadgeColor}`}>
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
</span>
{a.completedAt ? (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">Tamam</Badge>
) : (
<Badge className="text-[10px] h-4 px-1.5">Açık</Badge>
)}
</div>
<p className="text-sm font-medium leading-tight mb-1.5">{a.title}</p>
{a.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-1.5">{a.description}</p>
)}
<div className="flex flex-col gap-0.5 mb-2">
{customerName && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0" />
<span className="truncate">{customerName}</span>
</div>
)}
{propertyTitle && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Buildings className="size-3 shrink-0" />
<span className="truncate">{propertyTitle}</span>
</div>
)}
</div>
<div className="flex gap-1.5">
{!a.completedAt && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2 flex-1"
onClick={() => onComplete(a)}
>
<CheckCircle className="size-3 mr-1" />
Tamamla
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2 flex-1"
onClick={() => onEdit(a)}
>
<PencilSimple className="size-3 mr-1" />
Düzenle
</Button>
</div>
</div>
);
}
@@ -1,20 +1,12 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions";
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
@@ -30,18 +22,8 @@ interface ActivityFormSheetProps {
onSuccess?: () => void;
}
export function ActivityFormSheet({
open,
onOpenChange,
activity,
customers,
properties,
onSuccess,
}: ActivityFormSheetProps) {
const action = activity
? updateActivityAction.bind(null, activity.$id)
: createActivityAction;
export function ActivityFormSheet({ open, onOpenChange, activity, customers, properties, onSuccess }: ActivityFormSheetProps) {
const action = activity ? updateActivityAction.bind(null, activity.$id) : createActivityAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
useEffect(() => {
@@ -56,86 +38,82 @@ export function ActivityFormSheet({
const fe = state.fieldErrors ?? {};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>{activity ? "Aktiviteyi Düzenle" : "Yeni Aktivite"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="mt-4 space-y-4 pb-6">
<div className="grid gap-1.5">
<Label>Tip *</Label>
<select
name="type"
defaultValue={activity?.type ?? "gorusme"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value="gorusme">Görüşme</option>
<option value="teklif">Teklif</option>
<option value="ziyaret">Ziyaret</option>
<option value="arama">Arama</option>
<option value="not">Not</option>
</select>
const steps = [
{
label: "Aktivite",
content: (
<>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5">
<Label>Tip *</Label>
<select name="type" defaultValue={activity?.type ?? "gorusme"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="gorusme">Görüşme</option>
<option value="teklif">Teklif</option>
<option value="ziyaret">Ziyaret</option>
<option value="arama">Arama</option>
<option value="not">Not</option>
</select>
</div>
<div className="col-span-2 grid gap-1.5">
<Label htmlFor="title">Başlık *</Label>
<Input id="title" name="title" defaultValue={activity?.title} placeholder="Görüşme notu..." />
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="title">Başlık *</Label>
<Input id="title" name="title" defaultValue={activity?.title} placeholder="Görüşme notu..." />
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
<Label htmlFor="dueDate">Tarih</Label>
<Input id="dueDate" name="dueDate" type="date"
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""} />
</div>
</>
),
},
{
label: "Bağlantı",
content: (
<>
<div className="grid gap-1.5">
<Label>Müşteri</Label>
<select
name="customerId"
defaultValue={activity?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<select name="customerId" defaultValue={activity?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Seçiniz</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label>İlan</Label>
<select
name="propertyId"
defaultValue={activity?.propertyId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<select name="propertyId" defaultValue={activity?.propertyId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Seçiniz</option>
{properties.map((p) => (
<option key={p.$id} value={p.$id}>{p.title}</option>
))}
</select>
</div>
</>
),
},
{
label: "Detay",
content: (
<div className="grid gap-1.5">
<Label htmlFor="description">Açıklama</Label>
<Textarea id="description" name="description" rows={5} defaultValue={activity?.description ?? ""} />
</div>
),
},
];
<div className="grid gap-1.5">
<Label htmlFor="dueDate">Tarih</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="description">Açıklama</Label>
<Textarea id="description" name="description" rows={3} defaultValue={activity?.description ?? ""} />
</div>
<SheetFooter>
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
{activity ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
return (
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
title={activity ? "Aktiviteyi Düzenle" : "Yeni Aktivite"}
maxWidth="sm:max-w-lg">
<form action={formAction}>
<FormWizard steps={steps} isPending={isPending} submitLabel={activity ? "Güncelle" : "Oluştur"} />
</form>
</ResponsiveSheet>
);
}
+34 -11
View File
@@ -2,22 +2,24 @@
import * as React from "react";
import {
Activity,
Building2,
ClipboardText,
Buildings,
CreditCard,
FileText,
LayoutDashboard,
SquaresFour,
Presentation,
Search,
Settings,
MagnifyingGlass,
GearSix,
TrendUp,
Users,
Wallet,
} from "lucide-react";
} from '@/lib/icons';
import Link from "next/link";
import { Logo } from "@/components/logo";
import { NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import { AcademySidebarBadge } from "@/components/academy/academy-sidebar-badge";
import {
Sidebar,
SidebarContent,
@@ -37,7 +39,7 @@ const navGroups = [
{
title: "Genel Bakış",
url: "/dashboard",
icon: LayoutDashboard,
icon: SquaresFour,
},
],
},
@@ -47,7 +49,7 @@ const navGroups = [
{
title: "İlanlar",
url: "/properties",
icon: Building2,
icon: Buildings,
},
],
},
@@ -82,7 +84,12 @@ const navGroups = [
{
title: "Aktiviteler",
url: "/activities",
icon: Activity,
icon: ClipboardText,
},
{
title: "Finans",
url: "/finance",
icon: TrendUp,
},
],
},
@@ -92,7 +99,7 @@ const navGroups = [
{
title: "Çalışma Alanı",
url: "/settings/workspace",
icon: Settings,
icon: GearSix,
items: [
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
{ title: "Ekip Üyeleri", url: "/settings/members" },
@@ -115,11 +122,26 @@ const navGroups = [
export function AppSidebar({
user,
company,
pendingMatchCount = 0,
...props
}: React.ComponentProps<typeof Sidebar> & {
user: ShellUser;
company: ShellCompany;
pendingMatchCount?: number;
}) {
// Inject badge into the Eşleşmeler sub-item
const groups = navGroups.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
items: item.items?.map((sub) =>
sub.url === "/customers/matches" && pendingMatchCount > 0
? { ...sub, badge: pendingMatchCount }
: sub,
),
})),
}));
return (
<Sidebar {...props}>
<SidebarHeader>
@@ -151,11 +173,12 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navGroups.map((group) => (
{groups.map((group) => (
<NavMain key={group.label} label={group.label} items={group.items} />
))}
</SidebarContent>
<SidebarFooter>
<AcademySidebarBadge />
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Crown } from "lucide-react";
import { Crown } from '@/lib/icons';
import { Button } from "@/components/ui/button";
import {
+14 -14
View File
@@ -4,16 +4,16 @@ import * as React from "react";
import { useRouter } from "next/navigation";
import { Command as CommandPrimitive } from "cmdk";
import {
Activity,
Building2,
LayoutDashboard,
ClipboardText,
Buildings,
SquaresFour,
Presentation,
Search,
Settings,
MagnifyingGlass,
GearSix,
Users,
Wallet,
type LucideIcon,
} from "lucide-react";
type Icon,
} from '@/lib/icons';
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
@@ -33,16 +33,16 @@ const Command = React.forwardRef<
));
Command.displayName = CommandPrimitive.displayName;
type NavItem = { title: string; url: string; icon: LucideIcon };
type NavItem = { title: string; url: string; icon: Icon };
const NAV_ITEMS: NavItem[] = [
{ title: "Genel Bakış", url: "/dashboard", icon: LayoutDashboard },
{ title: "İlanlar", url: "/properties", icon: Building2 },
{ title: "Genel Bakış", url: "/dashboard", icon: SquaresFour },
{ title: "İlanlar", url: "/properties", icon: Buildings },
{ title: "Müşteriler", url: "/customers", icon: Users },
{ title: "Yatırımcılar", url: "/investors", icon: Wallet },
{ title: "Sunumlar", url: "/presentations", icon: Presentation },
{ title: "Aktiviteler", url: "/activities", icon: Activity },
{ title: "Ofis Ayarları", url: "/settings/workspace", icon: Settings },
{ title: "Aktiviteler", url: "/activities", icon: ClipboardText },
{ title: "Ofis Ayarları", url: "/settings/workspace", icon: GearSix },
];
export function CommandSearch() {
@@ -71,7 +71,7 @@ export function CommandSearch() {
onClick={() => setOpen(true)}
className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-sm"
>
<Search className="size-3.5" />
<MagnifyingGlass className="size-3.5" />
<span>Ara...</span>
<kbd className="bg-background rounded border px-1 text-xs">K</kbd>
</button>
@@ -81,7 +81,7 @@ export function CommandSearch() {
<DialogTitle className="sr-only">Arama</DialogTitle>
<Command>
<div className="flex items-center border-b px-3">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<MagnifyingGlass className="mr-2 size-4 shrink-0 opacity-50" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -1,22 +1,16 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { CircleNotch } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createCustomerAction, updateCustomerAction } from "@/lib/appwrite/customer-actions";
import type { Customer } from "@/lib/appwrite/schema";
import { CUSTOMER_STAGE_LABELS, CUSTOMER_SOURCE_LABELS } from "@/lib/appwrite/schema";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
const INITIAL: ActionState = { ok: false };
@@ -47,55 +41,103 @@ export function CustomerFormSheet({ open, onOpenChange, customer, onSuccess }: C
const fe = state.fieldErrors ?? {};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>{customer ? "Müşteriyi Düzenle" : "Yeni Müşteri"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="mt-4 space-y-4 pb-6">
const steps = [
{
label: "Kimlik",
content: (
<>
<div className="grid gap-1.5">
<Label htmlFor="name">Ad Soyad *</Label>
<Input id="name" name="name" defaultValue={customer?.name} placeholder="Ahmet Yılmaz" />
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
</div>
<div className="grid gap-1.5">
<Label>Müşteri tipi *</Label>
<select name="type" defaultValue={customer?.type ?? "alici"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="alici">Alıcı</option>
<option value="kiraci">Kiracı</option>
<option value="yatirimci">Yatırımcı</option>
</select>
{fe.type && <p className="text-destructive text-xs">{fe.type[0]}</p>}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Müşteri tipi *</Label>
<select name="type" defaultValue={customer?.type ?? "alici"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="alici">Alıcı</option>
<option value="kiraci">Kiracı</option>
<option value="yatirimci">Yatırımcı</option>
</select>
{fe.type && <p className="text-destructive text-xs">{fe.type[0]}</p>}
</div>
<div className="grid gap-1.5">
<Label>Aşama</Label>
<select name="stage" defaultValue={customer?.stage ?? "ilk_temas"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
{Object.entries(CUSTOMER_STAGE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={customer?.phone ?? ""} placeholder="+90 555 123 45 67" />
</>
),
},
{
label: "İletişim",
content: (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={customer?.phone ?? ""} placeholder="+90 555 123 45 67" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" defaultValue={customer?.email ?? ""} placeholder="ahmet@example.com" />
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" defaultValue={customer?.email ?? ""} placeholder="ahmet@example.com" />
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Kaynak</Label>
<select name="source" defaultValue={customer?.source ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Seçiniz</option>
{Object.entries(CUSTOMER_SOURCE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="nextFollowUpDate">Takip tarihi</Label>
<Input id="nextFollowUpDate" name="nextFollowUpDate" type="date"
defaultValue={customer?.nextFollowUpDate ? customer.nextFollowUpDate.split("T")[0] : ""} />
</div>
</div>
</>
),
},
{
label: "Not",
content: (
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={4} defaultValue={customer?.notes ?? ""} placeholder="Müşteri hakkında notlar..." />
</div>
),
},
];
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} defaultValue={customer?.notes ?? ""} placeholder="Müşteri hakkında notlar..." />
</div>
<SheetFooter>
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
{customer ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={customer ? "Müşteriyi Düzenle" : "Yeni Müşteri"}
maxWidth="sm:max-w-lg"
>
<form
action={formAction}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "TEXTAREA") {
e.preventDefault();
}
}}
>
<FormWizard steps={steps} isPending={isPending} submitLabel={customer ? "Güncelle" : "Oluştur"} />
</form>
</ResponsiveSheet>
);
}
+220 -77
View File
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, List, Columns } from '@/lib/icons';
import { toast } from "sonner";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -14,33 +16,58 @@ import {
} from "@/components/ui/dropdown-menu";
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
import { CustomerFormSheet } from "./customer-form-sheet";
import type { Customer } from "@/lib/appwrite/schema";
import { CUSTOMER_TYPE_LABELS } from "@/lib/appwrite/schema";
import { CustomersPipeline } from "./customers-pipeline";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema";
import {
CUSTOMER_TYPE_LABELS,
CUSTOMER_STAGE_LABELS,
CUSTOMER_SOURCE_LABELS,
} from "@/lib/appwrite/schema";
type ViewMode = "list" | "pipeline";
interface CustomersClientProps {
initialCustomers: Customer[];
}
export function CustomersClient({ initialCustomers }: CustomersClientProps) {
const router = useRouter();
const [customers, setCustomers] = useState(initialCustomers);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [typeFilter, setTypeFilter] = useState<CustomerType | "all">("all");
const [stageFilter, setStageFilter] = useState<CustomerStage | "all">("all");
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
const filteredCustomers = useMemo(() => {
let list = customers;
if (typeFilter !== "all") list = list.filter((c) => c.type === typeFilter);
if (stageFilter !== "all") list = list.filter((c) => (c.stage ?? "ilk_temas") === stageFilter);
return list;
}, [customers, typeFilter, stageFilter]);
function openEdit(c: Customer) {
setEditing(c);
setSheetOpen(true);
}
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-customers", open);
window.addEventListener("kovak:close-form-customers", close);
return () => {
window.removeEventListener("kovak:open-form-customers", open);
window.removeEventListener("kovak:close-form-customers", close);
};
}, []);
async function handleDelete(c: Customer) {
if (!confirm(`"${c.name}" silinsin mi?`)) return;
const result = await deleteCustomerAction(c.$id);
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(c: Customer) { setEditing(c); setSheetOpen(true); }
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteCustomerAction(deleteTarget.$id);
if (result.ok) {
setCustomers((prev) => prev.filter((x) => x.$id !== c.$id));
setCustomers((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("Müşteri silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -48,77 +75,193 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) {
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 flex-1">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Müşteriler</h1>
<Button onClick={openCreate} size="sm">
<Plus className="mr-1.5 size-4" />
Yeni Müşteri
</Button>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
viewMode === "list"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<List className="size-3.5" />
Liste
</button>
<button
type="button"
onClick={() => setViewMode("pipeline")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors border-l ${
viewMode === "pipeline"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<Columns className="size-3.5" />
Pipeline
</button>
</div>
<Button onClick={openCreate} size="sm" data-tour="customers-add">
<Plus className="mr-1.5 size-4" />
Yeni Müşteri
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad Soyad</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Email</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{customers.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground text-center py-10">
Henüz müşteri yok.
</TableCell>
</TableRow>
)}
{customers.map((c) => (
<TableRow key={c.$id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>
<Badge variant="outline">
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{c.email ?? "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(c)}>
<Pencil className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(c)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Filter bar */}
<div className="flex flex-wrap gap-2 items-center">
<div className="flex gap-1">
{([["all", "Tümü"], ["alici", "Alıcı"], ["kiraci", "Kiracı"], ["yatirimci", "Yatırımcı"]] as const).map(([key, label]) => {
const count = key === "all" ? customers.length : customers.filter((c) => c.type === key).length;
if (key !== "all" && count === 0) return null;
return (
<button key={key} type="button"
onClick={() => setTypeFilter(key)}
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
typeFilter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
})}
</div>
<div className="flex gap-1 ml-2 pl-2 border-l">
{(["all", "ilk_temas", "aktif_arama", "teklif", "sozlesme", "kapandi"] as const).map((key) => {
const label = key === "all" ? "Tüm aşamalar" : CUSTOMER_STAGE_LABELS[key];
const count = key === "all" ? customers.length : customers.filter((c) => (c.stage ?? "ilk_temas") === key).length;
if (key !== "all" && count === 0) return null;
return (
<button key={key} type="button"
onClick={() => setStageFilter(key)}
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
stageFilter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
})}
</div>
</div>
{/* Pipeline view */}
{viewMode === "pipeline" && (
<CustomersPipeline customers={filteredCustomers} onEdit={openEdit} />
)}
{/* List view */}
{viewMode === "list" && (
<div data-tour="customers-table" className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad Soyad</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Aşama</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Kaynak</TableHead>
<TableHead>Takip</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filteredCustomers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
{customers.length === 0 ? "Henüz müşteri yok." : "Filtreye uyan müşteri yok."}
</TableCell>
</TableRow>
)}
{filteredCustomers.map((c) => (
<TableRow key={c.$id}>
<TableCell className="font-medium">
<Link href={`/customers/${c.$id}`} className="hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
</Badge>
</TableCell>
<TableCell>
<StageBadge stage={c.stage} />
</TableCell>
<TableCell className="text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{c.source ? (CUSTOMER_SOURCE_LABELS[c.source] ?? c.source) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{c.nextFollowUpDate
? new Date(c.nextFollowUpDate).toLocaleDateString("tr-TR")
: "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(c)}>
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteTarget(c)}
className="text-destructive focus:text-destructive"
>
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CustomerFormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
customer={editing}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`"${deleteTarget?.name}" silinsin mi?`}
description="Bu müşteri kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
}
function StageBadge({ stage }: { stage?: string | null }) {
const colors: Record<string, string> = {
ilk_temas: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
aktif_arama: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
teklif: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
sozlesme: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
kapandi: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
};
const key = stage ?? "ilk_temas";
const label = CUSTOMER_STAGE_LABELS[key as keyof typeof CUSTOMER_STAGE_LABELS] ?? key;
return (
<span className={`inline-flex text-xs font-medium px-2 py-0.5 rounded-full ${colors[key] ?? colors.ilk_temas}`}>
{label}
</span>
);
}
@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { Phone, Envelope, CaretRight } from '@/lib/icons';
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { updateCustomerStageAction } from "@/lib/appwrite/customer-actions";
import {
CUSTOMER_STAGE_LABELS,
CUSTOMER_TYPE_LABELS,
type Customer,
type CustomerStage,
} from "@/lib/appwrite/schema";
const STAGES: CustomerStage[] = [
"ilk_temas",
"aktif_arama",
"teklif",
"sozlesme",
"kapandi",
];
const STAGE_COLORS: Record<CustomerStage, string> = {
ilk_temas: "bg-slate-100 dark:bg-slate-800/60",
aktif_arama: "bg-blue-50 dark:bg-blue-950/40",
teklif: "bg-amber-50 dark:bg-amber-950/40",
sozlesme: "bg-purple-50 dark:bg-purple-950/40",
kapandi: "bg-emerald-50 dark:bg-emerald-950/40",
};
const STAGE_HEADER_COLORS: Record<CustomerStage, string> = {
ilk_temas: "border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400",
aktif_arama: "border-blue-300 text-blue-700 dark:border-blue-600 dark:text-blue-400",
teklif: "border-amber-300 text-amber-700 dark:border-amber-600 dark:text-amber-400",
sozlesme: "border-purple-300 text-purple-700 dark:border-purple-600 dark:text-purple-400",
kapandi: "border-emerald-300 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400",
};
interface CustomersPipelineProps {
customers: Customer[];
onEdit: (c: Customer) => void;
}
export function CustomersPipeline({ customers, onEdit }: CustomersPipelineProps) {
const [items, setItems] = useState(customers);
async function moveStage(customer: Customer, stage: CustomerStage) {
setItems((prev) =>
prev.map((c) => (c.$id === customer.$id ? { ...c, stage } : c)),
);
const result = await updateCustomerStageAction(customer.$id, stage);
if (!result.ok) {
setItems((prev) =>
prev.map((c) => (c.$id === customer.$id ? customer : c)),
);
toast.error(result.error ?? "Aşama güncellenemedi.");
}
}
const grouped = STAGES.reduce<Record<CustomerStage, Customer[]>>(
(acc, s) => {
acc[s] = items.filter((c) => (c.stage ?? "ilk_temas") === s);
return acc;
},
{} as Record<CustomerStage, Customer[]>,
);
return (
<div className="flex gap-3 overflow-x-auto pb-2 min-h-0 flex-1">
{STAGES.map((stage) => (
<div key={stage} className="flex flex-col gap-2 w-60 shrink-0">
{/* Column header */}
<div className={`flex items-center justify-between rounded-lg border px-3 py-2 ${STAGE_HEADER_COLORS[stage]}`}>
<span className="text-sm font-semibold">{CUSTOMER_STAGE_LABELS[stage]}</span>
<span className="text-xs font-mono bg-white/60 dark:bg-black/20 rounded px-1.5 py-0.5">
{grouped[stage].length}
</span>
</div>
{/* Cards */}
<div className={`flex flex-col gap-2 rounded-xl p-2 min-h-[6rem] flex-1 ${STAGE_COLORS[stage]}`}>
{grouped[stage].length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">Müşteri yok</p>
)}
{grouped[stage].map((c) => (
<CustomerCard
key={c.$id}
customer={c}
currentStage={stage}
onMove={moveStage}
onEdit={onEdit}
/>
))}
</div>
</div>
))}
</div>
);
}
function CustomerCard({
customer,
currentStage,
onMove,
onEdit,
}: {
customer: Customer;
currentStage: CustomerStage;
onMove: (c: Customer, s: CustomerStage) => void;
onEdit: (c: Customer) => void;
}) {
const currentIdx = STAGES.indexOf(currentStage);
const nextStage = STAGES[currentIdx + 1] as CustomerStage | undefined;
return (
<div
className="bg-card rounded-lg border p-3 space-y-2 shadow-xs hover:shadow-sm transition-shadow cursor-pointer group"
onClick={() => onEdit(customer)}
>
<div className="flex items-start justify-between gap-1">
<p className="text-sm font-medium leading-snug line-clamp-2 flex-1">{customer.name}</p>
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
{CUSTOMER_TYPE_LABELS[customer.type] ?? customer.type}
</Badge>
</div>
{(customer.phone || customer.email) && (
<div className="space-y-0.5">
{customer.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="size-3" />
<span className="truncate">{customer.phone}</span>
</div>
)}
{customer.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Envelope className="size-3" />
<span className="truncate">{customer.email}</span>
</div>
)}
</div>
)}
{customer.nextFollowUpDate && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
📅 {new Date(customer.nextFollowUpDate).toLocaleDateString("tr-TR")}
</p>
)}
{nextStage && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onMove(customer, nextStage); }}
className="w-full flex items-center justify-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-dashed rounded py-0.5 hover:border-foreground/30 transition-colors opacity-0 group-hover:opacity-100"
>
<CaretRight className="size-3" />
{CUSTOMER_STAGE_LABELS[nextStage]}
</button>
)}
</div>
);
}
+85 -160
View File
@@ -1,24 +1,13 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
createCustomerSearchAction,
updateCustomerSearchAction,
} from "@/lib/appwrite/customer-search-actions";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createCustomerSearchAction, updateCustomerSearchAction } from "@/lib/appwrite/customer-search-actions";
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
@@ -34,20 +23,23 @@ const WEIGHT_OPTIONS = [
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>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs shrink-0">Önem:</span>
<select name={name} defaultValue={String(defaultValue ?? 3)}
className="border-input bg-background h-8 rounded-md border px-2 text-xs">
{WEIGHT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}
function parseJsonToInput(json?: string | null): string {
if (!json) return "";
try { return (JSON.parse(json) as string[]).join(", "); } catch { return json; }
}
interface SearchFormSheetProps {
open: boolean;
onOpenChange: (v: boolean) => void;
@@ -57,18 +49,8 @@ interface SearchFormSheetProps {
onSuccess?: () => void;
}
export function SearchFormSheet({
open,
onOpenChange,
search,
customers,
defaultCustomerId,
onSuccess,
}: SearchFormSheetProps) {
const action = search
? updateCustomerSearchAction.bind(null, search.$id)
: createCustomerSearchAction;
export function SearchFormSheet({ open, onOpenChange, search, customers, defaultCustomerId, onSuccess }: SearchFormSheetProps) {
const action = search ? updateCustomerSearchAction.bind(null, search.$id) : createCustomerSearchAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
useEffect(() => {
@@ -83,165 +65,108 @@ export function SearchFormSheet({
const fe = state.fieldErrors ?? {};
function parseJsonToInput(json?: string | null): string {
if (!json) return "";
try {
const arr = JSON.parse(json) as string[];
return arr.join(", ");
} catch {
return json;
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="mt-4 space-y-5 pb-6">
{/* Müşteri */}
const steps = [
{
label: "Müşteri",
content: (
<>
<div className="grid gap-1.5">
<Label>Müşteri *</Label>
<select
name="customerId"
defaultValue={search?.customerId ?? defaultCustomerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<select name="customerId" defaultValue={search?.customerId ?? defaultCustomerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Müşteri seçin</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}
</option>
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
{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
name="listingType"
defaultValue={search?.listingType ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<select name="listingType" defaultValue={search?.listingType ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Tümü</option>
<option value="satilik">Satılık</option>
<option value="kiralik">Kiralık</option>
</select>
</div>
{/* Emlak tipi + ağırlık */}
</>
),
},
{
label: "Emlak & Boyut",
content: (
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
<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>
<Input
id="propertyTypes"
name="propertyTypes"
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>
<Label>Emlak tipleri</Label>
<Input name="propertyTypes" defaultValue={parseJsonToInput(search?.propertyTypes)} placeholder="daire, villa" />
<WeightSelect name="propertyTypeWeight" defaultValue={search?.propertyTypeWeight} />
</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>
<Input
id="roomCounts"
name="roomCounts"
defaultValue={parseJsonToInput(search?.roomCounts)}
placeholder="2+1, 3+1"
/>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-muted-foreground text-xs">Önem:</span>
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
</div>
<Label>Oda sayıları</Label>
<Input name="roomCounts" defaultValue={parseJsonToInput(search?.roomCounts)} placeholder="2+1, 3+1" />
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
</div>
{/* Fiyat aralığı + ağırlık */}
<div className="grid gap-1.5">
<Label>Fiyat aralığı</Label>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1">
<span className="text-muted-foreground text-xs">Min</span>
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground text-xs block mb-1">Min</span>
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} placeholder="0" />
</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>
<span className="text-muted-foreground text-xs block mb-1">Max</span>
<Input name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} placeholder="—" />
</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>
<WeightSelect name="priceWeight" defaultValue={search?.priceWeight} />
</div>
{/* M2 aralığı + ağırlık */}
<div className="grid gap-1.5">
<Label>m² aralığı</Label>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1">
<span className="text-muted-foreground text-xs">Min</span>
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground text-xs block mb-1">Min</span>
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} placeholder="0" />
</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>
<span className="text-muted-foreground text-xs block mb-1">Max</span>
<Input name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} placeholder="—" />
</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>
<WeightSelect name="m2Weight" defaultValue={search?.m2Weight} />
</div>
{/* Konum + ağırlık */}
</div>
),
},
{
label: "Konum",
content: (
<>
<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>
<Label>Şehirler</Label>
<Input name="cities" defaultValue={parseJsonToInput(search?.cities)} placeholder="İstanbul, Ankara" />
<WeightSelect name="locationWeight" defaultValue={search?.locationWeight} />
</div>
{/* Notlar */}
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={search?.notes ?? ""} />
<Label>İlçeler</Label>
<Input name="districts" defaultValue={parseJsonToInput(search?.districts)} placeholder="Kadıköy, Beşiktaş" />
</div>
<div className="grid gap-1.5">
<Label>Notlar</Label>
<Textarea name="notes" rows={3} defaultValue={search?.notes ?? ""} />
</div>
</>
),
},
];
<SheetFooter>
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
{search ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
return (
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
title={search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}
maxWidth="sm:max-w-xl">
<form action={formAction}>
<FormWizard steps={steps} isPending={isPending} submitLabel={search ? "Güncelle" : "Oluştur"} />
</form>
</ResponsiveSheet>
);
}
+24 -18
View File
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Pencil, Trash2, ToggleLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, ToggleLeft } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -17,6 +18,7 @@ import {
toggleCustomerSearchActiveAction,
} from "@/lib/appwrite/customer-search-actions";
import { SearchFormSheet } from "./search-form-sheet";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
interface SearchesClientProps {
@@ -25,23 +27,18 @@ interface SearchesClientProps {
}
export function SearchesClient({ initialSearches, customers }: SearchesClientProps) {
const router = useRouter();
const [searches, setSearches] = useState(initialSearches);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<CustomerSearch | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomerSearch | null>(null);
function customerName(id: string) {
return customers.find((c) => c.$id === id)?.name ?? id;
}
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
function openEdit(s: CustomerSearch) {
setEditing(s);
setSheetOpen(true);
}
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(s: CustomerSearch) { setEditing(s); setSheetOpen(true); }
async function handleToggle(s: CustomerSearch) {
const next = !s.isActive;
@@ -53,11 +50,12 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
}
}
async function handleDelete(s: CustomerSearch) {
if (!confirm("Bu arama kriteri silinsin mi?")) return;
const result = await deleteCustomerSearchAction(s.$id);
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteCustomerSearchAction(deleteTarget.$id);
if (result.ok) {
setSearches((prev) => prev.filter((x) => x.$id !== s.$id));
setSearches((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("Arama kriteri silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -132,12 +130,12 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(s)}>
<Pencil className="mr-2 size-4" />
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggle(s)}>
@@ -145,10 +143,10 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
{s.isActive ? "Pasif yap" : "Aktif yap"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(s)}
onClick={() => setDeleteTarget(s)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
@@ -165,6 +163,14 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
onOpenChange={setSheetOpen}
search={editing}
customers={customers}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title="Bu arama kriteri silinsin mi?"
description="Arama kriteri ve eşleşme bildirimleri kalıcı olarak silinecek."
onConfirm={doDelete}
/>
</div>
);
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useActionState, startTransition } from "react";
import { toast } from "sonner";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createDealAction, updateDealAction } from "@/lib/appwrite/deal-actions";
import type { Customer, Deal } from "@/lib/appwrite/schema";
interface DealFormSheetProps {
open: boolean;
onOpenChange: (v: boolean) => void;
deal?: Deal | null;
customers: Customer[];
onSuccess: () => void;
}
const EMPTY_STATE: { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> } = { ok: false };
export function DealFormSheet({ open, onOpenChange, deal, customers, onSuccess }: DealFormSheetProps) {
const action = deal ? updateDealAction.bind(null, deal.$id) : createDealAction;
const [state, dispatch, isPending] = useActionState(action, EMPTY_STATE);
useEffect(() => {
if (state.ok) {
toast.success(deal ? "İşlem güncellendi." : "İşlem kaydedildi.");
onSuccess();
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => dispatch(fd));
}
const steps = [
{
label: "İşlem",
content: (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Tip</Label>
<Select name="type" defaultValue={deal?.type ?? "satis"} required>
<SelectTrigger><SelectValue placeholder="Seç" /></SelectTrigger>
<SelectContent>
<SelectItem value="satis">Satış</SelectItem>
<SelectItem value="kiralama">Kiralama</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>İlan Başlığı</Label>
<Input name="propertyTitle" placeholder="İsteğe bağlı" defaultValue={deal?.propertyTitle ?? ""} />
</div>
</div>
<div className="grid gap-1.5">
<Label>Müşteri</Label>
<select
name="customerId"
defaultValue={deal?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value=""> Seçiniz (opsiyonel)</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label>Satış / Kira Bedeli ()</Label>
<Input name="salePrice" type="number" min={0} step="any" required placeholder="0"
defaultValue={deal?.salePrice ?? ""} />
{state.fieldErrors?.salePrice && (
<p className="text-destructive text-xs">{state.fieldErrors.salePrice[0]}</p>
)}
</div>
</>
),
},
{
label: "Komisyon",
content: (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Oran (%)</Label>
<Input name="commissionRate" type="number" min={0} max={100} step="any" required placeholder="3"
defaultValue={deal?.commissionRate ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Tutar ()</Label>
<Input name="commissionAmount" type="number" min={0} step="any" required placeholder="0"
defaultValue={deal?.commissionAmount ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Ofis payı (%)</Label>
<Input name="officeSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
defaultValue={deal?.officeSharePercent ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Danışman payı (%)</Label>
<Input name="agentSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
defaultValue={deal?.agentSharePercent ?? ""} />
</div>
</div>
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Referans / Ortak Danışman</p>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5 col-span-2">
<Label>Ad Soyad</Label>
<Input name="referralName" placeholder="Ahmet Yılmaz (opsiyonel)"
defaultValue={deal?.referralName ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Telefon</Label>
<Input name="referralPhone" placeholder="05xx xxx xx xx"
defaultValue={deal?.referralPhone ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Komisyondan pay (%)</Label>
<Input name="referralPercent" type="number" min={0} max={100} step="any"
placeholder="örn. 25"
defaultValue={deal?.referralPercent ?? ""} />
</div>
</div>
</div>
</div>
),
},
{
label: "Kapanış",
content: (
<>
<div className="grid gap-1.5">
<Label>Kapanış Tarihi</Label>
<Input name="closingDate" type="date"
defaultValue={deal?.closingDate ? deal.closingDate.slice(0, 10) : ""} />
</div>
<div className="grid gap-1.5">
<Label>Notlar</Label>
<Textarea name="notes" rows={4} placeholder="Opsiyonel notlar..." defaultValue={deal?.notes ?? ""} />
</div>
</>
),
},
];
return (
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
title={deal ? "İşlemi Düzenle" : "Yeni İşlem"}
maxWidth="sm:max-w-lg">
<form onSubmit={handleSubmit}>
<FormWizard steps={steps} isPending={isPending} submitLabel={deal ? "Güncelle" : "Kaydet"} />
</form>
</ResponsiveSheet>
);
}
+459
View File
@@ -0,0 +1,459 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, XCircle, TrendUp, Wallet, Users, Clock, UserCheck } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { DealFormSheet } from "./deal-form-sheet";
import {
updateDealStatusAction,
deleteDealAction,
} from "@/lib/appwrite/deal-actions";
import type { Deal, DealStatus, Customer } from "@/lib/appwrite/schema";
import {
DEAL_TYPE_LABELS,
DEAL_STATUS_LABELS,
} from "@/lib/appwrite/schema";
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
interface FinanceClientProps {
initialDeals: Deal[];
role: TenantRole;
userId: string;
userName: string;
customers: Customer[];
}
const STATUS_FILTER_OPTIONS = [
["all", "Tümü"],
["bekleyen", "Bekleyen"],
["tahsil_edildi", "Tahsil Edildi"],
["iptal", "İptal"],
] as const;
function fmt(n: number) {
return n.toLocaleString("tr-TR", { maximumFractionDigits: 0 });
}
export function FinanceClient({ initialDeals, role, userId, customers }: FinanceClientProps) {
const router = useRouter();
const [deals, setDeals] = useState<Deal[]>(initialDeals);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Deal | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Deal | null>(null);
const [statusFilter, setStatusFilter] = useState<DealStatus | "all">("all");
const isOwnerOrAdmin = role === "owner" || role === "admin";
const filtered = useMemo(() => {
if (statusFilter === "all") return deals;
return deals.filter((d) => (d.status ?? "bekleyen") === statusFilter);
}, [deals, statusFilter]);
// ---- Stats ----
const stats = useMemo(() => {
const collected = deals.filter((d) => d.status === "tahsil_edildi");
const pending = deals.filter((d) => (d.status ?? "bekleyen") === "bekleyen");
const totalCommission = collected.reduce((s, d) => s + d.commissionAmount, 0);
const pendingCommission = pending.reduce((s, d) => s + d.commissionAmount, 0);
const agentEarnings = collected.reduce(
(s, d) =>
s + (d.agentSharePercent != null
? (d.commissionAmount * d.agentSharePercent) / 100
: d.commissionAmount),
0,
);
const officeEarnings = collected.reduce(
(s, d) =>
s + (d.officeSharePercent != null
? (d.commissionAmount * d.officeSharePercent) / 100
: 0),
0,
);
const referralPaid = collected.reduce(
(s, d) =>
s + (d.referralPercent != null
? (d.commissionAmount * d.referralPercent) / 100
: 0),
0,
);
// Group by agent for leaderboard (owner/admin only)
const byAgent: Record<string, { name: string; count: number; earnings: number }> = {};
collected.forEach((d) => {
if (!byAgent[d.agentId]) byAgent[d.agentId] = { name: d.agentName, count: 0, earnings: 0 };
byAgent[d.agentId].count++;
byAgent[d.agentId].earnings +=
d.agentSharePercent != null
? (d.commissionAmount * d.agentSharePercent) / 100
: d.commissionAmount;
});
const leaderboard = Object.entries(byAgent)
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.earnings - a.earnings);
return { totalCommission, pendingCommission, agentEarnings, officeEarnings, referralPaid, leaderboard, pendingCount: pending.length };
}, [deals]);
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-finance", open);
window.addEventListener("kovak:close-form-finance", close);
return () => {
window.removeEventListener("kovak:open-form-finance", open);
window.removeEventListener("kovak:close-form-finance", close);
};
}, []);
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(d: Deal) { setEditing(d); setSheetOpen(true); }
async function handleStatusChange(deal: Deal, status: DealStatus) {
setDeals((prev) =>
prev.map((d) => (d.$id === deal.$id ? { ...d, status } : d)),
);
const result = await updateDealStatusAction(deal.$id, status);
if (!result.ok) {
setDeals((prev) =>
prev.map((d) => (d.$id === deal.$id ? deal : d)),
);
toast.error(result.error ?? "Durum güncellenemedi.");
}
}
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteDealAction(deleteTarget.$id);
if (result.ok) {
setDeals((prev) => prev.filter((d) => d.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("İşlem silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
}
}
return (
<div className="flex flex-col gap-6 flex-1">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Finans</h1>
<Button onClick={openCreate} size="sm" data-tour="finance-add">
<Plus className="mr-1.5 size-4" />
Yeni İşlem
</Button>
</div>
{/* Stats cards */}
{isOwnerOrAdmin ? (
<div data-tour="finance-stats" className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-5">
<StatCard
icon={TrendUp}
label="Toplam Komisyon"
value={`${fmt(stats.totalCommission)}`}
sub="tahsil edilen"
color="text-emerald-600"
/>
<StatCard
icon={Clock}
label="Bekleyen Komisyon"
value={`${fmt(stats.pendingCommission)}`}
sub={`${stats.pendingCount} işlem`}
color="text-amber-600"
/>
<StatCard
icon={Wallet}
label="Ofis Geliri"
value={`${fmt(stats.officeEarnings)}`}
sub="tahsil edilen paydan"
color="text-blue-600"
/>
<StatCard
icon={Users}
label="Danışman Geliri"
value={`${fmt(stats.agentEarnings)}`}
sub="tahsil edilen paydan"
color="text-purple-600"
/>
{stats.referralPaid > 0 && (
<StatCard
icon={UserCheck}
label="Referans Ödemesi"
value={`${fmt(stats.referralPaid)}`}
sub="tahsil edilenden"
color="text-rose-600"
/>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<StatCard
icon={TrendUp}
label="Kazancım"
value={`${fmt(stats.agentEarnings)}`}
sub="tahsil edilen"
color="text-emerald-600"
/>
<StatCard
icon={Clock}
label="Bekleyen"
value={`${fmt(stats.pendingCommission)}`}
sub={`${stats.pendingCount} işlem`}
color="text-amber-600"
/>
<StatCard
icon={Wallet}
label="Toplam İşlem"
value={String(deals.length)}
sub="kayıtlı"
color="text-blue-600"
/>
</div>
)}
{/* Leaderboard (owner/admin only) */}
{isOwnerOrAdmin && stats.leaderboard.length > 0 && (
<div className="rounded-lg border p-4">
<p className="text-sm font-semibold mb-3">Danışman Performansı</p>
<div className="flex flex-col gap-2">
{stats.leaderboard.map((agent, i) => (
<div key={agent.id} className="flex items-center gap-3">
<span className="text-muted-foreground text-xs w-4">{i + 1}.</span>
<span className="text-sm flex-1 truncate">{agent.name}</span>
<span className="text-xs text-muted-foreground">{agent.count} işlem</span>
<span className="text-sm font-medium tabular-nums">{fmt(agent.earnings)}</span>
</div>
))}
</div>
</div>
)}
{/* Status filter */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTER_OPTIONS.map(([key, label]) => {
const count = key === "all" ? deals.length : deals.filter((d) => (d.status ?? "bekleyen") === key).length;
if (key !== "all" && count === 0) return null;
return (
<button
key={key}
type="button"
onClick={() => setStatusFilter(key as DealStatus | "all")}
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
statusFilter === key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
})}
</div>
{/* Table */}
<div data-tour="finance-table" className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>İlan / Müşteri</TableHead>
<TableHead>Tip</TableHead>
{isOwnerOrAdmin && <TableHead>Danışman</TableHead>}
<TableHead>Satış Bedeli</TableHead>
<TableHead>Komisyon</TableHead>
<TableHead>Danışman Payı</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Tarih</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell
colSpan={isOwnerOrAdmin ? 9 : 8}
className="text-muted-foreground text-center py-10"
>
{deals.length === 0 ? "Henüz işlem yok." : "Filtreye uyan işlem yok."}
</TableCell>
</TableRow>
)}
{filtered.map((deal) => {
const agentEarning =
deal.agentSharePercent != null
? (deal.commissionAmount * deal.agentSharePercent) / 100
: deal.commissionAmount;
const canEdit = isOwnerOrAdmin || deal.agentId === userId;
return (
<TableRow key={deal.$id} className={deal.status === "iptal" ? "opacity-50" : ""}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-sm truncate max-w-[180px]">
{deal.propertyTitle ?? "—"}
</span>
{deal.customerName && (
<span className="text-muted-foreground text-xs">{deal.customerName}</span>
)}
{deal.referralName && (
<span className="text-xs text-rose-600 flex items-center gap-0.5 mt-0.5">
<UserCheck className="size-3 shrink-0" />
{deal.referralName}
{deal.referralPercent != null && ` (%${deal.referralPercent})`}
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{DEAL_TYPE_LABELS[deal.type]}
</Badge>
</TableCell>
{isOwnerOrAdmin && (
<TableCell className="text-sm text-muted-foreground">
{deal.agentName}
</TableCell>
)}
<TableCell className="tabular-nums text-sm">
{fmt(deal.salePrice)}
</TableCell>
<TableCell className="tabular-nums text-sm">
{fmt(deal.commissionAmount)}
<span className="text-muted-foreground text-xs ml-1">
(%{deal.commissionRate})
</span>
</TableCell>
<TableCell className="tabular-nums text-sm font-medium">
{fmt(agentEarning)}
{deal.agentSharePercent != null && (
<span className="text-muted-foreground text-xs ml-1">
(%{deal.agentSharePercent})
</span>
)}
</TableCell>
<TableCell>
<DealStatusBadge status={deal.status} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{deal.closingDate
? new Date(deal.closingDate).toLocaleDateString("tr-TR")
: "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={() => openEdit(deal)}>
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
)}
{isOwnerOrAdmin && deal.status !== "tahsil_edildi" && (
<DropdownMenuItem onClick={() => handleStatusChange(deal, "tahsil_edildi")}>
<CheckCircle className="mr-2 size-4 text-emerald-600" />
Tahsil Edildi
</DropdownMenuItem>
)}
{isOwnerOrAdmin && deal.status !== "iptal" && (
<DropdownMenuItem onClick={() => handleStatusChange(deal, "iptal")}>
<XCircle className="mr-2 size-4 text-destructive" />
İptal Et
</DropdownMenuItem>
)}
{isOwnerOrAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteTarget(deal)}
className="text-destructive focus:text-destructive"
>
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DealFormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
deal={editing}
customers={customers}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`Bu işlem silinsin mi?`}
description="İşlem kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
sub,
color,
}: {
icon: React.ElementType;
label: string;
value: string;
sub: string;
color: string;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<Icon className={`size-4 ${color}`} />
<span className="text-muted-foreground text-xs">{label}</span>
</div>
<p className="text-xl font-bold tabular-nums">{value}</p>
<p className="text-muted-foreground text-xs">{sub}</p>
</div>
);
}
function DealStatusBadge({ status }: { status?: string | null }) {
const map: Record<string, string> = {
bekleyen: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
tahsil_edildi: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
iptal: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
};
const key = status ?? "bekleyen";
const label = DEAL_STATUS_LABELS[key as keyof typeof DEAL_STATUS_LABELS] ?? key;
return (
<span className={`inline-flex text-xs font-medium px-2 py-0.5 rounded-full ${map[key] ?? map.bekleyen}`}>
{label}
</span>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Building2, User } from "lucide-react";
import { Buildings, User } from '@/lib/icons';
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -36,7 +36,7 @@ export function ScopeToggle({
)}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="size-4" />
<Buildings className="size-4" />
Şirket
</div>
<p className="text-muted-foreground text-xs">
@@ -1,20 +1,12 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { createInvestorAction, updateInvestorAction } from "@/lib/appwrite/investor-actions";
import type { Investor } from "@/lib/appwrite/schema";
@@ -29,10 +21,7 @@ interface InvestorFormSheetProps {
}
export function InvestorFormSheet({ open, onOpenChange, investor, onSuccess }: InvestorFormSheetProps) {
const action = investor
? updateInvestorAction.bind(null, investor.$id)
: createInvestorAction;
const action = investor ? updateInvestorAction.bind(null, investor.$id) : createInvestorAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
useEffect(() => {
@@ -47,63 +36,76 @@ export function InvestorFormSheet({ open, onOpenChange, investor, onSuccess }: I
const fe = state.fieldErrors ?? {};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>{investor ? "Yatırımcıyı Düzenle" : "Yeni Yatırımcı"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="mt-4 space-y-4 pb-6">
<div className="grid gap-1.5">
<Label htmlFor="name">Ad Soyad *</Label>
<Input id="name" name="name" defaultValue={investor?.name} placeholder="Mehmet Demir" />
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
const steps = [
{
label: "Kişi",
content: (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="name">Ad Soyad *</Label>
<Input id="name" name="name" defaultValue={investor?.name} placeholder="Mehmet Demir" />
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
</div>
<div className="grid gap-1.5">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={investor?.phone ?? ""} placeholder="+90 555 123 45 67" />
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="email">Email *</Label>
<Input id="email" name="email" type="email" defaultValue={investor?.email} placeholder="mehmet@example.com" />
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
</div>
</>
),
},
{
label: "Finansal",
content: (
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 grid gap-1.5">
<Label htmlFor="budget">Bütçe</Label>
<Input id="budget" name="budget" type="number" min="0"
defaultValue={investor?.budget ?? ""} placeholder="5.000.000" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={investor?.phone ?? ""} placeholder="+90 555 123 45 67" />
<Label>Para birimi</Label>
<select name="currency" defaultValue={investor?.currency ?? "TRY"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="TRY">TRY</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
</div>
),
},
{
label: "Not",
content: (
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={5} defaultValue={investor?.notes ?? ""} placeholder="Yatırım tercihleri..." />
</div>
),
},
];
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="budget">Bütçe</Label>
<Input id="budget" name="budget" type="number" min="0" defaultValue={investor?.budget ?? ""} placeholder="5000000" />
</div>
<div className="grid gap-1.5">
<Label>Para birimi</Label>
<select
name="currency"
defaultValue={investor?.currency ?? "TRY"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value="TRY">TRY</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} defaultValue={investor?.notes ?? ""} placeholder="Yatırım tercihleri..." />
</div>
<SheetFooter>
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
{investor ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
return (
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
title={investor ? "Yatırımcıyı Düzenle" : "Yeni Yatırımcı"}
maxWidth="sm:max-w-lg">
<form
action={formAction}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "TEXTAREA") {
e.preventDefault();
}
}}
>
<FormWizard steps={steps} isPending={isPending} submitLabel={investor ? "Güncelle" : "Oluştur"} />
</form>
</ResponsiveSheet>
);
}
+24 -18
View File
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { deleteInvestorAction } from "@/lib/appwrite/investor-actions";
import { InvestorFormSheet } from "./investor-form-sheet";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Investor } from "@/lib/appwrite/schema";
interface InvestorsClientProps {
@@ -20,25 +22,21 @@ interface InvestorsClientProps {
}
export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
const router = useRouter();
const [investors, setInvestors] = useState(initialInvestors);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Investor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Investor | null>(null);
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(i: Investor) { setEditing(i); setSheetOpen(true); }
function openEdit(i: Investor) {
setEditing(i);
setSheetOpen(true);
}
async function handleDelete(i: Investor) {
if (!confirm(`"${i.name}" silinsin mi?`)) return;
const result = await deleteInvestorAction(i.$id);
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteInvestorAction(deleteTarget.$id);
if (result.ok) {
setInvestors((prev) => prev.filter((x) => x.$id !== i.$id));
setInvestors((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("Yatırımcı silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -86,19 +84,19 @@ export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(i)}>
<Pencil className="mr-2 size-4" />
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(i)}
onClick={() => setDeleteTarget(i)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
@@ -114,6 +112,14 @@ export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
open={sheetOpen}
onOpenChange={setSheetOpen}
investor={editing}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`"${deleteTarget?.name}" silinsin mi?`}
description="Bu yatırımcı kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
+11 -11
View File
@@ -2,24 +2,24 @@
import {
Shield,
BarChart3,
ChartBar,
Database,
Building2,
Buildings,
Rocket,
Settings,
Zap,
GearSix,
Lightning,
Package,
Layout,
Crown,
Palette
} from 'lucide-react'
} from '@/lib/icons'
const menuSections = [
{
title: 'Browse Products',
items: [
{
title: 'Free Blocks',
title: 'Free SquaresFour',
description: 'Essential UI components and sections',
icon: Package,
href: '#free-blocks'
@@ -33,7 +33,7 @@ const menuSections = [
{
title: 'Admin Dashboards',
description: 'Full-featured dashboard solutions',
icon: BarChart3,
icon: ChartBar,
href: '#admin-dashboards'
},
{
@@ -50,7 +50,7 @@ const menuSections = [
{
title: 'E-commerce',
description: 'Online store admin panels and components',
icon: Building2,
icon: Buildings,
href: '#ecommerce'
},
{
@@ -62,7 +62,7 @@ const menuSections = [
{
title: 'Analytics',
description: 'Data visualization and reporting templates',
icon: BarChart3,
icon: ChartBar,
href: '#analytics'
},
{
@@ -91,13 +91,13 @@ const menuSections = [
{
title: 'GitHub Repository',
description: 'Open source foundation and community',
icon: Settings,
icon: GearSix,
href: '#github'
},
{
title: 'Design System',
description: 'shadcn/ui standards and customization',
icon: Zap,
icon: Lightning,
href: '#design-system'
}
]
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { MapPin } from "lucide-react";
import { MapPin } from '@/lib/icons';
import type { Property } from "@/lib/appwrite/schema";
const Inner = dynamic(
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { Search, Loader2, MapPin, X } from "lucide-react";
import { MagnifyingGlass, CircleNotch, MapPin, X } from '@/lib/icons';
import { Input } from "@/components/ui/input";
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
@@ -198,10 +198,10 @@ export function PropertyMapPickerInner({
return (
<div className="space-y-2">
{/* Search input with autocomplete dropdown */}
{/* MagnifyingGlass input with autocomplete dropdown */}
<div className="relative" ref={dropdownRef}>
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
<MagnifyingGlass className="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
<Input
className="pl-8 pr-8"
placeholder="Adres, mahalle veya şehir ara..."
@@ -211,7 +211,7 @@ export function PropertyMapPickerInner({
onFocus={() => suggestions.length > 0 && setShowDropdown(true)}
/>
{loading && (
<Loader2 className="text-muted-foreground absolute right-2.5 top-2.5 size-4 animate-spin" />
<CircleNotch className="text-muted-foreground absolute right-2.5 top-2.5 size-4 animate-spin" />
)}
</div>
@@ -238,7 +238,7 @@ export function PropertyMapPickerInner({
)}
</div>
{/* Map */}
{/* MapTrifold */}
<div
ref={containerRef}
className="h-64 w-full overflow-hidden rounded-lg border"
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { MapPin } from "lucide-react";
import { MapPin } from '@/lib/icons';
const Inner = dynamic(
() =>
+138 -30
View File
@@ -1,9 +1,17 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { BellRinging, Checks } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MatchBreakdownDialog } from "./match-breakdown-dialog";
import { markMatchNotifiedAction, markAllNotifiedAction } from "@/lib/appwrite/match-actions";
import type { Property, PropertyMatch, CustomerSearch, Customer } from "@/lib/appwrite/schema";
type Tab = "pending" | "all";
interface MatchesClientProps {
matches: PropertyMatch[];
customers: Customer[];
@@ -14,87 +22,187 @@ interface MatchesClientProps {
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";
s >= 80 ? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
: s >= 60 ? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
: s >= 40 ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400";
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
>
<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 router = useRouter();
const [items, setItems] = useState(matches);
const [tab, setTab] = useState<Tab>("pending");
const [selectedMatch, setSelectedMatch] = useState<PropertyMatch | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [notifyingId, setNotifyingId] = useState<string | null>(null);
const [notifyingAll, setNotifyingAll] = 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]));
const pendingCount = items.filter((m) => !m.notified).length;
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
function openBreakdown(m: PropertyMatch) {
setSelectedMatch(m);
setDialogOpen(true);
}
async function handleNotify(m: PropertyMatch) {
setNotifyingId(m.$id);
const result = await markMatchNotifiedAction(m.$id);
setNotifyingId(null);
if (result.ok) {
setItems((prev) => prev.map((x) => x.$id === m.$id ? { ...x, notified: true } : x));
router.refresh();
} else {
toast.error(result.error ?? "Güncelleme başarısız.");
}
}
async function handleNotifyAll() {
const pendingIds = items.filter((m) => !m.notified).map((m) => m.$id);
if (pendingIds.length === 0) return;
setNotifyingAll(true);
const result = await markAllNotifiedAction(pendingIds);
setNotifyingAll(false);
if (result.ok) {
setItems((prev) => prev.map((x) => ({ ...x, notified: true })));
router.refresh();
toast.success(`${pendingIds.length} eşleşme bildirildi olarak işaretlendi.`);
} else {
toast.error(result.error ?? "Toplu güncelleme başarısız.");
}
}
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>
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{pendingCount > 0
? `${pendingCount} bildirilmemiş eşleşme`
: "Tüm eşleşmeler bildirildi"}
</p>
</div>
{pendingCount > 0 && (
<Button size="sm" variant="outline" onClick={handleNotifyAll} disabled={notifyingAll}>
<Checks className="mr-1.5 size-4" />
Tümünü bildirildi işaretle
</Button>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{(["pending", "all"] as Tab[]).map((key) => {
const label = key === "pending" ? "Bekleyen" : "Tümü";
const count = key === "pending" ? pendingCount : items.length;
return (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
tab === key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{label}
<span className={`ml-1.5 text-xs rounded-full px-1.5 py-0.5 ${
tab === key ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<tr className="border-b bg-muted/30">
<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>
<th className="p-3 text-left font-medium hidden md:table-cell">Tarih</th>
<th className="p-3 text-left font-medium">Durum</th>
<th className="p-3" />
</tr>
</thead>
<tbody>
{matches.length === 0 && (
{visible.length === 0 && (
<tr>
<td colSpan={5} className="text-muted-foreground py-10 text-center">
Henüz eşleşme yok.
<td colSpan={6} className="text-muted-foreground py-10 text-center">
{tab === "pending" ? "Bekleyen eşleşme yok." : "Henüz eşleşme yok."}
</td>
</tr>
)}
{matches.map((m) => {
{visible.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"
className={`border-b last:border-0 ${
!m.notified ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
}`}
>
<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">
<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>
)}
</td>
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
<p className="truncate">{property?.title ?? m.propertyId}</p>
{property?.city && (
<p className="text-xs text-muted-foreground">{property.city}</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.viewedAt ? (
<span className="text-xs text-green-600">
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
{m.notified ? (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Checks className="size-3.5 text-green-500" />
Bildirildi
</span>
) : (
<span className="text-muted-foreground text-xs">Hayır</span>
<span className="text-xs text-amber-600 font-medium flex items-center gap-1">
<BellRinging className="size-3.5" />
Bekliyor
</span>
)}
</td>
<td className="p-3">
{!m.notified && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={notifyingId === m.$id}
onClick={() => handleNotify(m)}
>
<BellRinging className="size-3 mr-1" />
Bildir
</Button>
)}
</td>
</tr>
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { Moon, Sun } from '@/lib/icons'
import { Button } from "@/components/ui/button"
import { useTheme } from "@/hooks/use-theme"
+9 -3
View File
@@ -1,6 +1,6 @@
"use client"
import { ChevronRight, type LucideIcon } from "lucide-react"
import { CaretRight, type Icon } from '@/lib/icons'
import Link from "next/link"
import { usePathname } from "next/navigation"
@@ -28,12 +28,13 @@ export function NavMain({
items: {
title: string
url: string
icon?: LucideIcon
icon?: Icon
isActive?: boolean
items?: {
title: string
url: string
isActive?: boolean
badge?: number
}[]
}[]
}) {
@@ -63,7 +64,7 @@ export function NavMain({
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -77,6 +78,11 @@ export function NavMain({
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>
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { type LucideIcon } from "lucide-react"
import { type Icon } from '@/lib/icons'
import Link from "next/link"
import {
@@ -17,7 +17,7 @@ export function NavSecondary({
items: {
title: string
url: string
icon: LucideIcon
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
+9 -9
View File
@@ -2,12 +2,12 @@
import { useTransition } from "react";
import {
BellDot,
CircleUser,
BellSimple,
UserCircle,
CreditCard,
EllipsisVertical,
LogOut,
} from "lucide-react";
DotsThreeVertical,
SignOut,
} from '@/lib/icons';
import Link from "next/link";
import {
@@ -62,7 +62,7 @@ export function NavUser({
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<EllipsisVertical className="ml-auto size-4" />
<DotsThreeVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -86,7 +86,7 @@ export function NavUser({
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/account">
<CircleUser />
<UserCircle />
Profil
</Link>
</DropdownMenuItem>
@@ -98,7 +98,7 @@ export function NavUser({
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/notifications">
<BellDot />
<BellSimple />
Bildirimler
</Link>
</DropdownMenuItem>
@@ -109,7 +109,7 @@ export function NavUser({
disabled={isPending}
className="cursor-pointer"
>
<LogOut />
<SignOut />
{isPending ? "Çıkış yapılıyor..." : "Çıkış yap"}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,7 +1,7 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { CircleNotch } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -9,13 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
createPresentationAction,
updatePresentationAction,
@@ -65,66 +59,61 @@ export function PresentationFormSheet({
const fe = state.fieldErrors ?? {};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>{presentation ? "Sunumu Düzenle" : "Yeni Sunum"}</SheetTitle>
</SheetHeader>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={presentation ? "Sunumu Düzenle" : "Yeni Sunum"}
>
<form action={formAction} className="space-y-4 pb-2">
<div data-tour="form-presentations-title" className="grid gap-1.5">
<Label htmlFor="title">Sunum başlığı *</Label>
<Input id="title" name="title" defaultValue={presentation?.title} placeholder="Kadıköy 3+1 Seçenekleri" />
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
</div>
<form action={formAction} className="mt-4 space-y-4 pb-6">
<div className="grid gap-1.5">
<Label htmlFor="title">Sunum başlığı *</Label>
<Input id="title" name="title" defaultValue={presentation?.title} placeholder="Kadıköy 3+1 Seçenekleri" />
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
</div>
<div className="grid gap-1.5">
<Label>Müşteri</Label>
<select
name="customerId"
defaultValue={presentation?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value="">Müşteri seçin (opsiyonel)</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label>Müşteri</Label>
<select
name="customerId"
defaultValue={presentation?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value="">Müşteri seçin (opsiyonel)</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
</div>
<div data-tour="form-presentations-properties" className="grid gap-1.5">
<Label>İlanlar *</Label>
{fe.propertyIds && <p className="text-destructive text-xs">{fe.propertyIds[0]}</p>}
<PropertyCheckboxList properties={properties} selectedIds={selectedIds} />
</div>
<div className="grid gap-1.5">
<Label>İlanlar *</Label>
{fe.propertyIds && <p className="text-destructive text-xs">{fe.propertyIds[0]}</p>}
<PropertyCheckboxList
properties={properties}
selectedIds={selectedIds}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="expiresAt">Geçerlilik tarihi</Label>
<Input
id="expiresAt"
name="expiresAt"
type="date"
defaultValue={presentation?.expiresAt ? presentation.expiresAt.split("T")[0] : ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="expiresAt">Geçerlilik tarihi</Label>
<Input
id="expiresAt"
name="expiresAt"
type="date"
defaultValue={presentation?.expiresAt ? presentation.expiresAt.split("T")[0] : ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={presentation?.notes ?? ""} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={2} defaultValue={presentation?.notes ?? ""} />
</div>
<SheetFooter>
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
{presentation ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
<div className="pt-2">
<Button type="submit" disabled={isPending} className="w-full">
{isPending && <CircleNotch className="mr-2 size-4 animate-spin" />}
{presentation ? "Güncelle" : "Oluştur"}
</Button>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -135,8 +124,6 @@ function PropertyCheckboxList({
properties: Property[];
selectedIds: string[];
}) {
// We encode selected propertyIds as a JSON array in a hidden input
// and use checkboxes with the same name to collect values
return (
<div className="max-h-48 overflow-y-auto rounded-md border p-2 space-y-1">
{properties.length === 0 && (
@@ -154,7 +141,6 @@ function PropertyCheckboxList({
</span>
</label>
))}
{/* Hidden field placeholder — server action reads propertyIdsRaw[] and joins them */}
</div>
);
}
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, Copy } from "lucide-react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, ArrowSquareOut, Copy } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
import { PresentationFormSheet } from "./presentation-form-sheet";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
interface PresentationsClientProps {
@@ -26,9 +28,11 @@ export function PresentationsClient({
customers,
properties,
}: PresentationsClientProps) {
const router = useRouter();
const [presentations, setPresentations] = useState(initialPresentations);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Presentation | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Presentation | null>(null);
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
@@ -49,26 +53,31 @@ export function PresentationsClient({
}
}
function openCreate() {
setEditing(null);
setSheetOpen(true);
}
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-presentations", open);
window.addEventListener("kovak:close-form-presentations", close);
return () => {
window.removeEventListener("kovak:open-form-presentations", open);
window.removeEventListener("kovak:close-form-presentations", close);
};
}, []);
function openEdit(p: Presentation) {
setEditing(p);
setSheetOpen(true);
}
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(p: Presentation) { setEditing(p); setSheetOpen(true); }
async function copyLink(p: Presentation) {
await navigator.clipboard.writeText(getShareUrl(p));
toast.success("Link kopyalandı.");
}
async function handleDelete(p: Presentation) {
if (!confirm(`"${p.title}" sunumu silinsin mi?`)) return;
const result = await deletePresentationAction(p.$id);
async function doDelete() {
if (!deleteTarget) return;
const result = await deletePresentationAction(deleteTarget.$id);
if (result.ok) {
setPresentations((prev) => prev.filter((x) => x.$id !== p.$id));
setPresentations((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("Sunum silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -79,13 +88,13 @@ export function PresentationsClient({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Sunumlar</h1>
<Button onClick={openCreate} size="sm">
<Button onClick={openCreate} size="sm" data-tour="presentations-add">
<Plus className="mr-1.5 size-4" />
Yeni Sunum
</Button>
</div>
<div className="rounded-md border">
<div data-tour="presentations-table" className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
@@ -118,7 +127,7 @@ export function PresentationsClient({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -128,19 +137,19 @@ export function PresentationsClient({
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={getShareUrl(p)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 size-4" />
<ArrowSquareOut className="mr-2 size-4" />
Önizleme
</a>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(p)}>
<Pencil className="mr-2 size-4" />
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(p)}
onClick={() => setDeleteTarget(p)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
@@ -158,6 +167,14 @@ export function PresentationsClient({
presentation={editing}
customers={customers}
properties={properties}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`"${deleteTarget?.title}" sunumu silinsin mi?`}
description="Sunum ve paylaşım linki kalıcı olarak silinecek."
onConfirm={doDelete}
/>
</div>
);
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Sparkles, Check } from "lucide-react"
import { Sparkle, Check } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface PricingPlan {
@@ -114,7 +114,7 @@ export function PricingPlans({
{tier.popular && (
<div className='absolute start-0 -top-3 w-full'>
<Badge className='mx-auto flex w-fit gap-1.5 rounded-full font-medium'>
<Sparkles className='!size-4' />
<Sparkle className='!size-4' />
{mode === 'pricing' && (
<span>Most Popular</span>
)}
@@ -0,0 +1,116 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, CaretLeft, CaretRight } from "@/lib/icons";
import { getPropertyImageUrl, getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
interface ImageLightboxProps {
imageIds: string[];
title: string;
initialIndex?: number;
onClose: () => void;
}
export function ImageLightbox({ imageIds, title, initialIndex = 0, onClose }: ImageLightboxProps) {
const [idx, setIdx] = useState(initialIndex);
const prev = useCallback(() => setIdx((i) => (i - 1 + imageIds.length) % imageIds.length), [imageIds.length]);
const next = useCallback(() => setIdx((i) => (i + 1) % imageIds.length), [imageIds.length]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [onClose, prev, next]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={onClose}
>
{/* Kapat */}
<button
type="button"
className="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
onClick={onClose}
aria-label="Kapat"
>
<X className="size-5" />
</button>
{/* Sayaç */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums select-none">
{idx + 1} / {imageIds.length}
</div>
{/* Önceki */}
{imageIds.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-3 sm:left-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
aria-label="Önceki"
>
<CaretLeft className="size-5" />
</button>
)}
{/* Ana görsel — orijinal boyut, kırpma yok */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImageUrl(imageIds[idx])}
alt={`${title} ${idx + 1}`}
className="max-h-[90dvh] max-w-[90vw] object-contain rounded-lg select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
{/* Sonraki */}
{imageIds.length > 1 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-3 sm:right-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
aria-label="Sonraki"
>
<CaretRight className="size-5" />
</button>
)}
{/* Thumbnail şeridi */}
{imageIds.length > 1 && (
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto max-w-[90vw] px-2 scrollbar-hide"
onClick={(e) => e.stopPropagation()}
>
{imageIds.map((id, i) => (
<button
key={id}
type="button"
onClick={() => setIdx(i)}
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
i === idx ? "border-white" : "border-white/20 hover:border-white/50"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(id, 160, 120)}
alt=""
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>,
document.body,
);
}
+509 -106
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useRef } from "react";
import React, { useState, useRef, useMemo, useEffect } from "react";
import Link from "next/link";
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, List, Map } from "lucide-react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, ArrowSquareOut, List, MapTrifold, MagnifyingGlass, X, ImageSquare, SquaresFour, CaretLeft, CaretRight } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -15,9 +16,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { deletePropertyAction } from "@/lib/appwrite/property-actions";
import { PropertyFormSheet } from "./property-form-sheet";
import { ImageLightbox } from "./image-lightbox";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { PropertiesMapView } from "@/components/map/properties-map-view";
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
import type { Property } from "@/lib/appwrite/schema";
import type { Property, PropertyStatus } from "@/lib/appwrite/schema";
import {
PROPERTY_STATUS_LABELS,
PROPERTY_TYPE_LABELS,
@@ -28,25 +31,41 @@ interface PropertiesClientProps {
initialProperties: Property[];
}
type ViewMode = "list" | "map";
type ViewMode = "list" | "gallery" | "map";
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
const router = useRouter();
const [properties, setProperties] = useState(initialProperties);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Property | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Property | null>(null);
const [statusFilter, setStatusFilter] = useState<PropertyStatus | "all">("all");
const [searchQuery, setSearchQuery] = useState("");
const rowRefs = useRef<Record<string, HTMLTableRowElement>>({});
const cardRefs = useRef<Record<string, HTMLDivElement>>({});
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-properties", open);
window.addEventListener("kovak:close-form-properties", close);
return () => {
window.removeEventListener("kovak:open-form-properties", open);
window.removeEventListener("kovak:close-form-properties", close);
};
}, []);
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(p: Property) { setEditing(p); setSheetOpen(true); }
async function handleDelete(p: Property) {
if (!confirm(`"${p.title}" silinsin mi?`)) return;
const result = await deletePropertyAction(p.$id);
async function doDelete() {
if (!deleteTarget) return;
const result = await deletePropertyAction(deleteTarget.$id);
if (result.ok) {
setProperties((prev) => prev.filter((x) => x.$id !== p.$id));
setProperties((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("İlan silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
@@ -64,7 +83,35 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
setSelectedId(id === selectedId ? null : id);
}
const mappedCount = properties.filter((p) => p.mapLat != null && p.mapLng != null).length;
const filteredProperties = useMemo(() => {
let list = properties;
if (statusFilter !== "all") list = list.filter((p) => p.status === statusFilter);
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter((p) =>
p.title.toLowerCase().includes(q) ||
(p.city ?? "").toLowerCase().includes(q) ||
(p.district ?? "").toLowerCase().includes(q),
);
}
return list;
}, [properties, statusFilter, searchQuery]);
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
const { withImages, withoutImages } = useMemo(() => ({
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
}), [filteredProperties]);
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
{ key: "all", label: "Tümü" },
{ key: "aktif", label: "Aktif" },
{ key: "rezerve", label: "Rezerve" },
{ key: "pasif", label: "Pasif" },
{ key: "satildi", label: "Satıldı" },
{ key: "kiralandit", label: "Kiralandı" },
];
return (
<div className="flex flex-col gap-4 flex-1">
@@ -72,121 +119,190 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-bold">İlanlar</h1>
{viewMode === "map" && mappedCount < properties.length && (
{viewMode === "map" && mappedCount < filteredProperties.length && (
<p className="text-xs text-muted-foreground mt-0.5">
{mappedCount} / {properties.length} ilanın koordinatı var
{mappedCount} / {filteredProperties.length} ilanın koordinatı var
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("list")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
viewMode === "list"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<List className="size-3.5" />
Liste
</button>
<button
type="button"
onClick={() => setViewMode("map")}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors border-l ${
viewMode === "map"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<Map className="size-3.5" />
Harita
</button>
{([
{ mode: "list" as ViewMode, icon: List, label: "Liste" },
{ mode: "gallery" as ViewMode, icon: SquaresFour, label: "Galeri" },
{ mode: "map" as ViewMode, icon: MapTrifold, label: "Harita" },
]).map(({ mode, icon: Icon, label }, i) => (
<button
key={mode}
type="button"
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${i > 0 ? "border-l" : ""} ${
viewMode === mode
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="size-3.5" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
<Button onClick={openCreate} size="sm">
<Button onClick={openCreate} size="sm" data-tour="properties-add">
<Plus className="mr-1.5 size-4" />
Yeni İlan
</Button>
</div>
</div>
{/* Filter bar */}
<div data-tour="properties-filters" className="flex flex-col gap-2 sm:flex-row sm:items-center">
{/* Status tabs */}
<div className="flex overflow-x-auto gap-1 shrink-0">
{STATUS_TABS.map(({ key, label }) => {
const count = key === "all" ? properties.length : properties.filter((p) => p.status === key).length;
if (key !== "all" && count === 0) return null;
return (
<button
key={key}
type="button"
onClick={() => setStatusFilter(key)}
className={`shrink-0 px-3 py-1 text-xs rounded-full font-medium transition-colors ${
statusFilter === key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
})}
</div>
{/* MagnifyingGlass */}
<div className="relative sm:ml-auto sm:w-56">
<MagnifyingGlass className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Başlık veya şehir ara…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-input bg-background h-8 rounded-md border pl-8 pr-7 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* List view */}
{viewMode === "list" && (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Başlık</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Şehir</TableHead>
<TableHead className="text-right">Fiyat</TableHead>
<TableHead>Durum</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{properties.length === 0 && (
<>
{/* Mobile cards — hidden on md+ */}
<div className="flex flex-col gap-3 md:hidden">
{filteredProperties.length === 0 && (
<p className="text-muted-foreground text-center py-10 text-sm">Henüz ilan yok.</p>
)}
{withImages.length > 0 && (
<>
{withoutImages.length > 0 && (
<ImageGroupHeader hasImages count={withImages.length} />
)}
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} 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} />)}
</div>
{/* Desktop table — hidden on mobile */}
<div data-tour="properties-table" className="hidden md:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
Henüz ilan yok.
</TableCell>
</TableRow>
)}
{properties.map((p) => (
<TableRow
key={p.$id}
ref={(el) => { if (el) rowRefs.current[p.$id] = el; }}
>
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
<TableCell className="text-right tabular-nums">
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(p)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableHead className="w-20" />
<TableHead>Başlık</TableHead>
<TableHead>Tip</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Şehir</TableHead>
<TableHead className="text-right">Fiyat</TableHead>
<TableHead>Durum</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filteredProperties.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground text-center py-10">
Henüz ilan yok.
</TableCell>
</TableRow>
)}
{withImages.length > 0 && withoutImages.length > 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={8} className="py-1.5 px-4">
<ImageGroupHeader hasImages count={withImages.length} />
</TableCell>
</TableRow>
)}
{withImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
{withImages.length > 0 && withoutImages.length > 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={8} className="py-1.5 px-4">
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
</TableCell>
</TableRow>
)}
{withoutImages.map((p) => (
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
))}
</TableBody>
</Table>
</div>
</>
)}
{/* Gallery view */}
{viewMode === "gallery" && (
<div>
{filteredProperties.length === 0 && (
<p className="text-muted-foreground text-center py-10 text-sm">Henüz ilan yok.</p>
)}
{withImages.length > 0 && (
<>
{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} />
))}
</div>
</>
)}
{withImages.length > 0 && withoutImages.length > 0 && (
<div className="mt-6 mb-3">
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
</div>
)}
{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} />
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{/* Map view — split layout */}
{/* MapTrifold view — split layout */}
{viewMode === "map" && (
<div
className="flex gap-4 min-h-0"
@@ -194,10 +310,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
>
{/* Left: scrollable property cards */}
<div className="w-72 shrink-0 overflow-y-auto space-y-2 pr-1 h-full">
{properties.length === 0 && (
{filteredProperties.length === 0 && (
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
)}
{properties.map((p) => {
{filteredProperties.map((p) => {
const coverImageId = parseImageIds(p.imageIds)[0];
const hasCoords = p.mapLat != null && p.mapLng != null;
const isSelected = selectedId === p.$id;
@@ -268,7 +384,7 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
{/* Right: map */}
<div className="flex-1 min-w-0 h-full">
<PropertiesMapView
properties={properties}
properties={filteredProperties}
selectedId={selectedId}
onSelect={handleMapSelect}
/>
@@ -280,15 +396,302 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
open={sheetOpen}
onOpenChange={setSheetOpen}
property={editing}
onSuccess={() => {}}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`"${deleteTarget?.title}" silinsin mi?`}
description="Bu ilan kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
}
/* ── Galeri kartı ── */
function GalleryCard({ p, openEdit, setDeleteTarget }: {
p: Property;
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);
return (
<div className="rounded-xl border bg-card overflow-hidden flex flex-col group">
{/* Görsel alanı */}
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
{imageIds.length > 0 ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(imageIds[safeIdx], 640, 480)}
alt={p.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02] cursor-zoom-in"
onClick={() => setLightbox(true)}
/>
{imageIds.length > 1 && (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i - 1 + imageIds.length) % imageIds.length); }}
className="absolute left-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<CaretLeft className="size-3.5" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i + 1) % imageIds.length); }}
className="absolute right-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<CaretRight className="size-3.5" />
</button>
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-1.5 py-0.5 rounded-full tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{safeIdx + 1}/{imageIds.length}
</div>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{imageIds.map((_, i) => (
<button
key={i}
type="button"
onClick={(e) => { e.stopPropagation(); setIdx(i); }}
className={`size-1.5 rounded-full transition-colors ${i === safeIdx ? "bg-white" : "bg-white/40"}`}
/>
))}
</div>
</>
)}
</>
) : (
<Link href={`/properties/${p.$id}`} className="flex h-full items-center justify-center flex-col gap-2">
<ImageSquare className="size-10 text-muted-foreground/30" />
<span className="text-xs text-muted-foreground/50">Görsel yok</span>
</Link>
)}
{/* Status badge üstte */}
<div className="absolute top-2 left-2">
<StatusBadge status={p.status} />
</div>
</div>
{lightbox && (
<ImageLightbox
imageIds={imageIds}
title={p.title}
initialIndex={safeIdx}
onClose={() => setLightbox(false)}
/>
)}
{/* Bilgi + aksiyonlar */}
<div className="p-3 flex flex-col gap-1.5 flex-1">
<Link href={`/properties/${p.$id}`} className="font-semibold text-sm leading-snug line-clamp-2 hover:underline">
{p.title}
</Link>
<p className="text-xs text-muted-foreground">
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
{" · "}{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
{p.city ? ` · ${p.city}` : ""}
{p.roomCount ? ` · ${p.roomCount}` : ""}
{p.netM2 ? ` · ${p.netM2}` : ""}
</p>
<div className="flex items-center justify-between mt-auto pt-1.5 border-t">
<p className="text-sm font-bold tabular-nums">
{p.price.toLocaleString("tr-TR")}
<span className="text-xs font-normal text-muted-foreground ml-1">{p.currency ?? "TRY"}</span>
</p>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="size-7" onClick={() => openEdit(p)}>
<PencilSimple className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(p)}
>
<Trash className="size-3.5" />
</Button>
</div>
</div>
</div>
</div>
);
}
/* ── Görsel grup ayracı ── */
function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: number }) {
return (
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1.5 text-xs font-semibold px-2 py-0.5 rounded-full ${
hasImages
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
}`}>
<ImageSquare className="size-3" />
{hasImages ? "Görselli" : "Görselsiz"}
<span className="opacity-70 font-normal">({count})</span>
</span>
<div className="flex-1 h-px bg-border" />
</div>
);
}
/* ── Mobil kart ── */
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
p: Property;
openEdit: (p: Property) => void;
setDeleteTarget: (p: Property) => void;
}) {
const coverImageId = parseImageIds(p.imageIds)[0];
return (
<div className="rounded-lg border bg-card overflow-hidden">
{coverImageId && (
<div className="h-36 overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(coverImageId, 640, 288)}
alt={p.title}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug line-clamp-2 flex-1">{p.title}</p>
<StatusBadge status={p.status} />
</div>
<p className="text-xs text-muted-foreground">
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
{" · "}{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
{p.city ? ` · ${p.city}` : ""}
{p.district ? `, ${p.district}` : ""}
{p.roomCount ? ` · ${p.roomCount}` : ""}
{p.netM2 ? ` · ${p.netM2}` : ""}
</p>
<div className="flex items-center justify-between">
<p className="text-base font-bold tabular-nums">
{p.price.toLocaleString("tr-TR")}
<span className="text-xs font-normal text-muted-foreground ml-1">{p.currency ?? "TRY"}</span>
</p>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link href={`/properties/${p.$id}`}>
<ArrowSquareOut className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => openEdit(p)}>
<PencilSimple className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(p)}
>
<Trash className="size-4" />
</Button>
</div>
</div>
</div>
</div>
);
}
/* ── Tablo satırı ── */
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
p: Property;
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);
return (
<>
<TableRow
ref={(el) => { if (el) rowRefs.current[p.$id] = el; }}
onClick={() => router.push(`/properties/${p.$id}`)}
className="cursor-pointer"
>
<TableCell className="py-1.5 pl-4 pr-2 w-20">
<div
onClick={(e) => { e.stopPropagation(); coverImageId && setLightbox(true); }}
className={`w-16 h-12 rounded overflow-hidden bg-muted flex items-center justify-center shrink-0 ${coverImageId ? "cursor-zoom-in" : ""}`}
>
{coverImageId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getPropertyImagePreviewUrl(coverImageId, 128, 96)}
alt=""
className="h-full w-full object-cover"
/>
) : (
<ImageSquare className="size-5 text-muted-foreground/30" />
)}
</div>
</TableCell>
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
<TableCell className="text-right tabular-nums">
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/properties/${p.$id}`}>
<ArrowSquareOut className="mr-2 size-4" />
Detay
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(p)}>
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteTarget(p)}
className="text-destructive focus:text-destructive"
>
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{lightbox && (
<ImageLightbox
imageIds={imageIds}
title={p.title}
initialIndex={0}
onClose={() => setLightbox(false)}
/>
)}
</>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
aktif: "default",
rezerve: "secondary",
pasif: "secondary",
satildi: "outline",
kiralandit: "outline",
@@ -0,0 +1,390 @@
"use client";
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 { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PropertyFormSheet } from "./property-form-sheet";
import { PropertyImageSheet } from "./property-image-sheet";
import { ImageLightbox } from "./image-lightbox";
import { PropertyMapView } from "@/components/map/property-map-view";
import { getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
import {
PROPERTY_TYPE_LABELS,
LISTING_TYPE_LABELS,
PROPERTY_STATUS_LABELS,
ACTIVITY_TYPE_LABELS,
type Property,
type PropertyMatch,
type Activity,
} from "@/lib/appwrite/schema";
import { getPropertyImageUrl } from "@/lib/appwrite/storage-utils";
interface Props {
property: Property;
matches: PropertyMatch[];
activities: Activity[];
imageIds: string[];
customerMap: Record<string, string>;
}
const STATUS_COLOR: Record<string, string> = {
aktif: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
pasif: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
satildi: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
kiralandit: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
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) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const location = [property.neighborhood, property.district, property.city]
.filter(Boolean).join(", ");
const formattedPrice = new Intl.NumberFormat("tr-TR").format(property.price);
return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 overflow-x-hidden">
{/* Üst bar: geri + düzenle */}
<div className="flex items-center justify-between gap-3">
<Link
href="/properties"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
İlanlar
</Link>
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} className="px-2 sm:px-3">
<PencilSimple className="size-3.5" />
<span className="hidden sm:inline">Düzenle</span>
</Button>
</div>
{/* Başlık */}
<div>
<h1 className="text-xl sm:text-2xl font-bold leading-tight">{property.title}</h1>
{location && (
<p className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<MapPin className="size-3.5 shrink-0" />
{location}
</p>
)}
</div>
{/* HERO: galeri (sol) + bilgi kartı (sağ) */}
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
{/* Galeri — her zaman solda, fotoğraf yoksa placeholder */}
<div className="lg:col-span-2">
<Gallery
imageIds={imageIds}
title={property.title}
type={property.propertyType}
onAddPhoto={() => setImageOpen(true)}
/>
</div>
{/* Bilgi kartı — sağda */}
<div className="rounded-xl border bg-card p-5 space-y-4">
{/* Status */}
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLOR[property.status] ?? "bg-zinc-100 text-zinc-600"}`}>
{PROPERTY_STATUS_LABELS[property.status] ?? property.status}
</span>
{/* Fiyat */}
<div>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-3xl font-bold tabular-nums">{formattedPrice}</span>
<span className="text-muted-foreground text-sm">{property.currency ?? "TRY"}</span>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{LISTING_TYPE_LABELS[property.listingType]} · {PROPERTY_TYPE_LABELS[property.propertyType]}
</p>
</div>
{/* Temel özellikler */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 pt-4 border-t text-sm">
{property.roomCount && <Spec label="Oda" value={property.roomCount} />}
{property.netM2 && <Spec label="Net m²" value={`${property.netM2}`} />}
{property.grossM2 && <Spec label="Brüt m²" value={`${property.grossM2}`} />}
{property.floor != null && <Spec label="Kat" value={String(property.floor)} />}
{property.totalFloors != null && <Spec label="Top. kat" value={String(property.totalFloors)} />}
{property.buildingAge != null && <Spec label="Bina yaşı" value={`${property.buildingAge} yıl`} />}
</div>
{/* Adres */}
{(property.address || property.mapLat != null) && (
<div className="pt-3 border-t space-y-1.5 text-sm">
{property.address && (
<p className="text-muted-foreground leading-snug">{property.address}</p>
)}
{property.mapLat != null && property.mapLng != null && (
<a
href={`https://www.google.com/maps?q=${property.mapLat},${property.mapLng}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline inline-flex items-center gap-1"
>
Google Maps&apos;te
</a>
)}
</div>
)}
</div>
</div>
{/* ALT KISIM: tabbed content */}
<Tabs defaultValue={property.description ? "aciklama" : property.mapLat != null ? "konum" : activities.length > 0 ? "aktiviteler" : "musteriler"}>
<TabsList className="flex h-auto w-full gap-1.5 rounded-xl bg-muted/60 p-1.5 flex-wrap">
{property.description && (
<TabsTrigger value="aciklama" className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium data-[state=active]:bg-background data-[state=active]:shadow-sm">
<FileText className="size-3.5" />
Açıklama
</TabsTrigger>
)}
{property.mapLat != null && property.mapLng != null && (
<TabsTrigger value="konum" className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium data-[state=active]:bg-background data-[state=active]:shadow-sm">
<MapPin className="size-3.5" />
Konum
</TabsTrigger>
)}
{activities.length > 0 && (
<TabsTrigger value="aktiviteler" className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium data-[state=active]:bg-background data-[state=active]:shadow-sm">
<ClipboardText className="size-3.5" />
Aktiviteler
<span className="ml-0.5 text-xs opacity-60">({activities.length})</span>
</TabsTrigger>
)}
<TabsTrigger value="musteriler" className="flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Users className="size-3.5" />
Müşteriler
{matches.length > 0 && (
<span className="ml-0.5 text-xs opacity-60">({matches.length})</span>
)}
</TabsTrigger>
</TabsList>
{property.description && (
<TabsContent value="aciklama" className="mt-4">
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{property.description}
</p>
</TabsContent>
)}
{property.mapLat != null && property.mapLng != null && (
<TabsContent value="konum" className="mt-4">
<div className="rounded-xl border overflow-hidden">
<PropertyMapView
lat={property.mapLat}
lng={property.mapLng}
title={property.title}
className="h-64 sm:h-80 rounded-none border-0"
/>
</div>
</TabsContent>
)}
{activities.length > 0 && (
<TabsContent value="aktiviteler" className="mt-4">
<div className="space-y-3">
{activities.map((a) => (
<div key={a.$id} className="flex items-start gap-3 text-sm border-b pb-3 last:border-0 last:pb-0">
<span className="shrink-0 mt-0.5 text-xs text-muted-foreground border rounded px-1.5 py-0.5">
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{a.title}</p>
{a.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{a.description}</p>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
</span>
</div>
))}
</div>
</TabsContent>
)}
<TabsContent value="musteriler" className="mt-4">
{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>
)}
</TabsContent>
</Tabs>
<PropertyFormSheet
open={editOpen}
onOpenChange={setEditOpen}
property={property}
onSuccess={() => router.refresh()}
/>
<PropertyImageSheet
open={imageOpen}
onOpenChange={setImageOpen}
propertyId={property.$id}
initialImageIds={imageIds}
onSuccess={() => router.refresh()}
/>
</div>
);
}
/* ── Galeri ── */
function Gallery({ imageIds, title, type, onAddPhoto }: {
imageIds: string[];
title: string;
type: string;
onAddPhoto: () => void;
}) {
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
if (imageIds.length === 0) {
return (
<div className="aspect-[16/10] rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/30 flex flex-col items-center justify-center gap-4">
<div className="size-16 rounded-full bg-background border flex items-center justify-center">
<ImageSquare className="size-7 text-muted-foreground/50" />
</div>
<div className="text-center">
<p className="text-sm font-medium">Fotoğraf eklenmedi</p>
<p className="text-xs text-muted-foreground mt-0.5">
{PROPERTY_TYPE_LABELS[type as keyof typeof PROPERTY_TYPE_LABELS] ?? type} · Görsel henüz yüklenmemiş
</p>
</div>
<Button variant="outline" size="sm" onClick={onAddPhoto}>
<Plus className="size-3.5" />
Fotoğraf Ekle
</Button>
</div>
);
}
const safeActive = Math.min(active, imageIds.length - 1);
const prev = () => setActive((i) => (i - 1 + imageIds.length) % imageIds.length);
const next = () => setActive((i) => (i + 1) % imageIds.length);
return (
<div className="space-y-2">
{/* Ana görsel */}
<div className="relative aspect-[16/10] rounded-xl overflow-hidden border bg-black group">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImageUrl(imageIds[safeActive])}
alt={`${title} ${safeActive + 1}`}
className="w-full h-full object-contain cursor-zoom-in"
onClick={() => setLightbox(true)}
/>
{/* Sayaç */}
<div className="absolute bottom-2.5 right-3 bg-black/50 text-white text-xs font-medium px-2 py-0.5 rounded-full tabular-nums">
{safeActive + 1} / {imageIds.length}
</div>
{/* Ok butonları — birden fazla görsel varsa */}
{imageIds.length > 1 && (
<>
<button
type="button"
onClick={prev}
className="absolute left-2 top-1/2 -translate-y-1/2 size-8 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Önceki"
>
<CaretLeft className="size-4" />
</button>
<button
type="button"
onClick={next}
className="absolute right-2 top-1/2 -translate-y-1/2 size-8 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Sonraki"
>
<CaretRight className="size-4" />
</button>
</>
)}
</div>
{/* Thumbnail şeridi */}
{imageIds.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-hide">
{imageIds.map((id, i) => (
<button
key={id}
type="button"
onClick={() => setActive(i)}
className={`relative shrink-0 w-16 h-12 sm:w-20 sm:h-14 rounded-md overflow-hidden border-2 transition-all ${
i === safeActive
? "border-primary"
: "border-transparent hover:border-muted-foreground/40"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPropertyImagePreviewUrl(id, 200, 150)}
alt={`${title} ${i + 1}`}
className="w-full h-full object-cover"
/>
{i !== safeActive && (
<div className="absolute inset-0 bg-black/20" />
)}
</button>
))}
</div>
)}
{lightbox && (
<ImageLightbox
imageIds={imageIds}
title={title}
initialIndex={safeActive}
onClose={() => setLightbox(false)}
/>
)}
</div>
);
}
/* ── Yardımcı bileşenler ── */
function Spec({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-semibold">{value}</p>
</div>
);
}
function ScoreBadge({ score }: { score?: number | null }) {
const s = score ?? 0;
const color =
s >= 80 ? "bg-emerald-100 text-emerald-700" :
s >= 60 ? "bg-blue-100 text-blue-700" :
s >= 40 ? "bg-yellow-100 text-yellow-700" :
"bg-zinc-100 text-zinc-500";
return (
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
{s}
</span>
);
}
+130 -111
View File
@@ -1,24 +1,29 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { FormWizard } from "@/components/ui/responsive-sheet";
import { createPropertyAction, updatePropertyAction } from "@/lib/appwrite/property-actions";
import { PropertyImageUploader } from "./property-image-uploader";
import { PropertyMapPicker } from "@/components/map/property-map-picker";
import { parseImageIds } from "@/lib/appwrite/storage-utils";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Property } from "@/lib/appwrite/schema";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
@@ -32,9 +37,8 @@ interface PropertyFormSheetProps {
}
export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: PropertyFormSheetProps) {
const action = property
? updatePropertyAction.bind(null, property.$id)
: createPropertyAction;
const isMobile = useIsMobile();
const action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction;
const [state, formAction, isPending] = useActionState(action, INITIAL);
const [mapLat, setMapLat] = useState<number | null>(property?.mapLat ?? null);
@@ -50,7 +54,6 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
}
}, [state]);
// Reset lat/lng when sheet opens for a different property
useEffect(() => {
if (open) {
setMapLat(property?.mapLat ?? null);
@@ -60,32 +63,21 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
const fe = state.fieldErrors ?? {};
const initialSearchQuery = [
property?.neighborhood,
property?.district,
property?.city,
]
.filter(Boolean)
.join(", ");
const initialSearchQuery = [property?.neighborhood, property?.district, property?.city]
.filter(Boolean).join(", ");
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{property ? "İlanı Düzenle" : "Yeni İlan"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="mt-4 space-y-4 pb-6">
{/* Hidden lat/lng — updated by map picker */}
const steps = [
{
label: "İlan",
content: (
<>
<input type="hidden" name="mapLat" value={mapLat ?? ""} />
<input type="hidden" name="mapLng" value={mapLng ?? ""} />
<div className="grid gap-1.5">
<Label htmlFor="title">Başlık *</Label>
<Input id="title" name="title" defaultValue={property?.title} placeholder="3+1 Daire, Kadıköy" />
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Emlak tipi *</Label>
@@ -108,7 +100,6 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="price">Fiyat *</Label>
@@ -120,13 +111,20 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
<select name="status" defaultValue={property?.status ?? "aktif"}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="aktif">Aktif</option>
<option value="rezerve">Rezerve</option>
<option value="pasif">Pasif</option>
<option value="satildi">Satıldı</option>
<option value="kiralandit">Kiralandı</option>
</select>
</div>
</div>
</>
),
},
{
label: "Konum",
content: (
<>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="city">Şehir *</Label>
@@ -142,95 +140,116 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
<Input id="neighborhood" name="neighborhood" defaultValue={property?.neighborhood ?? ""} placeholder="Moda" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="roomCount">Oda sayısı</Label>
<select name="roomCount" defaultValue={property?.roomCount ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value="">Seçiniz</option>
<option value="Stüdyo">Stüdyo</option>
<option value="1+0">1+0</option>
<option value="1+1">1+1</option>
<option value="2+1">2+1</option>
<option value="3+1">3+1</option>
<option value="4+1">4+1</option>
<option value="4+2">4+2</option>
<option value="5+1">5+1</option>
<option value="5+2">5+2</option>
<option value="6+">6+</option>
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="netM2">Net m²</Label>
<Input id="netM2" name="netM2" type="number" min="0" defaultValue={property?.netM2 ?? ""} placeholder="90" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="floor">Kat</Label>
<Input id="floor" name="floor" type="number" defaultValue={property?.floor ?? ""} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="totalFloors">Top. kat</Label>
<Input id="totalFloors" name="totalFloors" type="number" defaultValue={property?.totalFloors ?? ""} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="buildingAge">Bina yaşı</Label>
<Input id="buildingAge" name="buildingAge" type="number" min="0" defaultValue={property?.buildingAge ?? ""} />
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="address">Tam adres</Label>
<Textarea id="address" name="address" rows={2} defaultValue={property?.address ?? ""} placeholder="Sokak, kapı no..." />
<Label htmlFor="address">Adres</Label>
<Input id="address" name="address" defaultValue={property?.address ?? ""} placeholder="Sokak, kapı no..." />
</div>
{/* Map picker */}
{open && (
<PropertyMapPicker
initialLat={mapLat}
initialLng={mapLng}
initialSearchQuery={initialSearchQuery}
onLocationChange={(lat, lng) => { setMapLat(lat); setMapLng(lng); }}
onClear={() => { setMapLat(null); setMapLng(null); }}
/>
)}
</>
),
},
{
label: "Özellikler",
content: (
<div className="grid grid-cols-5 gap-3">
<div className="grid gap-1.5">
<Label>Konum (harita)</Label>
<p className="text-muted-foreground text-xs">
Adres arayın veya haritaya tıklayarak pin bırakın. Sürükleyerek hassaslaştırabilirsiniz.
</p>
{open && (
<PropertyMapPicker
initialLat={mapLat}
initialLng={mapLng}
initialSearchQuery={initialSearchQuery}
onLocationChange={(lat, lng) => {
setMapLat(lat);
setMapLng(lng);
}}
onClear={() => {
setMapLat(null);
setMapLng(null);
}}
/>
)}
<Label htmlFor="roomCount">Oda</Label>
<select name="roomCount" defaultValue={property?.roomCount ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value=""></option>
<option value="Stüdyo">Stüdyo</option>
<option value="1+0">1+0</option>
<option value="1+1">1+1</option>
<option value="2+1">2+1</option>
<option value="3+1">3+1</option>
<option value="4+1">4+1</option>
<option value="4+2">4+2</option>
<option value="5+1">5+1</option>
<option value="5+2">5+2</option>
<option value="6+">6+</option>
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="netM2">Net m²</Label>
<Input id="netM2" name="netM2" type="number" min="0" defaultValue={property?.netM2 ?? ""} placeholder="90" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="floor">Kat</Label>
<Input id="floor" name="floor" type="number" defaultValue={property?.floor ?? ""} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="totalFloors">Top. kat</Label>
<Input id="totalFloors" name="totalFloors" type="number" defaultValue={property?.totalFloors ?? ""} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="buildingAge">Yaş</Label>
<Input id="buildingAge" name="buildingAge" type="number" min="0" defaultValue={property?.buildingAge ?? ""} />
</div>
</div>
),
},
{
label: "Medya",
content: (
<>
<div className="grid gap-1.5">
<Label htmlFor="description">Açıklama</Label>
<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 className="grid gap-1.5">
<Label>Fotoğraflar</Label>
<PropertyImageUploader
name="imageIds"
initialImageIds={parseImageIds(property?.imageIds)}
/>
<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" />}
{property ? "Güncelle" : "Oluştur"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
const title = property ? "İlanı Düzenle" : "Yeni İlan";
const formContent = (
<form
action={formAction}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "TEXTAREA") {
e.preventDefault();
}
}}
>
<FormWizard steps={steps} isPending={isPending} submitLabel={property ? "Güncelle" : "Oluştur"} />
</form>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="bottom">
<DrawerContent className="max-h-[92dvh]">
<DrawerHeader className="border-b pb-3">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{formContent}</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div>{formContent}</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ImageSquare } from "@/lib/icons";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { PropertyImageUploader } from "./property-image-uploader";
import { updatePropertyImagesAction } from "@/lib/appwrite/property-actions";
import { useIsMobile } from "@/hooks/use-mobile";
interface PropertyImageSheetProps {
open: boolean;
onOpenChange: (v: boolean) => void;
propertyId: string;
initialImageIds: string[];
onSuccess?: () => void;
}
export function PropertyImageSheet({
open,
onOpenChange,
propertyId,
initialImageIds,
onSuccess,
}: PropertyImageSheetProps) {
const isMobile = useIsMobile();
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
const result = await updatePropertyImagesAction(propertyId, imageIds);
setSaving(false);
if (result.ok) {
toast.success("Fotoğraflar kaydedildi.");
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.error ?? "Kaydedilemedi.");
}
}
const content = (
<div className="space-y-4">
<PropertyImageUploader
name="imageIds"
initialImageIds={initialImageIds}
onChangeIds={setImageIds}
/>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
İptal
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor…" : "Kaydet"}
</Button>
</div>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="bottom">
<DrawerContent className="max-h-[92dvh]">
<DrawerHeader className="border-b pb-3">
<DrawerTitle className="flex items-center gap-2">
<ImageSquare className="size-4" />
Fotoğraf Yönetimi
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{content}</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ImageSquare className="size-4" />
Fotoğraf Yönetimi
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -1,62 +1,191 @@
"use client";
import { useState, useRef } from "react";
import { Upload, X, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import imageCompression from "browser-image-compression";
import { Upload, X } from '@/lib/icons';
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { uploadPropertyImageAction, deletePropertyImageAction } from "@/lib/appwrite/storage-actions";
import { deletePropertyImageAction } from "@/lib/appwrite/storage-actions";
import { getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
type UploadingFile = { uid: string; name: string; progress: number; phase: "compressing" | "uploading" };
interface PropertyImageUploaderProps {
name: string;
initialImageIds?: string[];
onChangeIds?: (ids: string[]) => void;
}
export function PropertyImageUploader({ name, initialImageIds = [] }: PropertyImageUploaderProps) {
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds }: PropertyImageUploaderProps) {
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [uploading, setUploading] = useState(false);
const [queue, setQueue] = useState<UploadingFile[]>([]);
const [dragging, setDragging] = 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 = "";
useEffect(() => {
onChangeIds?.(imageIds);
}, [imageIds]); // eslint-disable-line react-hooks/exhaustive-deps
function updateIds(updater: (prev: string[]) => string[]) {
setImageIds(updater);
}
const uploadOne = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error(`${file.name}: Sadece görsel dosyaları desteklenir.`);
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error(`${file.name}: 50 MB sınırını aşıyor.`);
return;
}
const uid = crypto.randomUUID();
setQueue((q) => [...q, { uid, name: file.name, progress: 0, phase: "compressing" }]);
let compressed: File;
try {
compressed = await imageCompression(file, {
maxSizeMB: 1.5,
maxWidthOrHeight: 1920,
useWebWorker: true,
initialQuality: 0.85,
fileType: file.type === "image/png" ? "image/png" : "image/jpeg",
});
} catch {
setQueue((q) => q.filter((u) => u.uid !== uid));
toast.error(`${file.name}: Sıkıştırma başarısız.`);
return;
}
setQueue((q) => q.map((u) => (u.uid === uid ? { ...u, phase: "uploading", progress: 0 } : u)));
const fd = new FormData();
fd.append("file", compressed);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
setQueue((q) => q.map((u) => (u.uid === uid ? { ...u, phase: "uploading", progress: pct } : u)));
}
});
xhr.addEventListener("load", () => {
setQueue((q) => q.filter((u) => u.uid !== uid));
try {
const res = JSON.parse(xhr.responseText) as { ok: boolean; fileId?: string; error?: string };
if (res.ok && res.fileId) {
updateIds((prev) => [...prev, res.fileId!]);
} else {
toast.error(res.error ?? "Yükleme başarısız.");
}
} catch {
toast.error("Yükleme başarısız.");
}
});
xhr.addEventListener("error", () => {
setQueue((q) => q.filter((u) => u.uid !== uid));
toast.error(`${file.name}: Bağlantı hatası.`);
});
xhr.open("POST", "/api/properties/images");
xhr.send(fd);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
Array.from(files).forEach(uploadOne);
if (inputRef.current) inputRef.current.value = "";
}, [uploadOne]);
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
}
async function handleDelete(fileId: string) {
const result = await deletePropertyImageAction(fileId);
if (result.ok) {
setImageIds((prev) => prev.filter((id) => id !== fileId));
updateIds((prev) => prev.filter((id) => id !== fileId));
} else {
toast.error(result.error ?? "Fotoğraf silinemedi");
}
}
const busy = queue.length > 0;
return (
<div className="space-y-3">
<input type="hidden" name={name} value={JSON.stringify(imageIds)} />
{/* Drop zone */}
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => e.key === "Enter" && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer select-none flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors",
dragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40 hover:bg-muted/30",
)}
>
<Upload className={cn("size-6", dragging ? "text-primary" : "text-muted-foreground")} />
<p className="text-sm">
<span className="font-medium text-primary">Fotoğraf seç</span>
<span className="text-muted-foreground"> veya buraya sürükle</span>
</p>
<p className="text-xs text-muted-foreground">PNG, JPG, WebP otomatik sıkıştırılır, birden fazla seçilebilir</p>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{/* Upload progress rows */}
{queue.map((u) => (
<div key={u.uid} className="space-y-1.5 rounded-lg border px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{u.name}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{u.phase === "compressing"
? "Sıkıştırılıyor…"
: u.progress < 100
? `%${u.progress}`
: "İşleniyor…"}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all duration-200",
u.phase === "compressing" ? "animate-pulse bg-amber-400 w-full" : "bg-primary",
)}
style={u.phase === "uploading" ? { width: `${u.progress}%` } : undefined}
/>
</div>
</div>
))}
{/* Thumbnail grid */}
{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"
>
<div key={id} className="group 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)}
@@ -66,7 +195,8 @@ export function PropertyImageUploader({ name, initialImageIds = [] }: PropertyIm
<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"
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"
>
<X className="size-3" />
</button>
@@ -74,30 +204,6 @@ export function PropertyImageUploader({ name, initialImageIds = [] }: PropertyIm
))}
</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>
);
}
+4 -22
View File
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { X } from "lucide-react"
import { X } from '@/lib/icons'
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Logo } from "./logo"
@@ -27,29 +27,11 @@ export function SidebarNotification() {
<div className="pr-6">
<h3 className="flex items-center gap-3 font-semibold text-neutral-900 dark:text-neutral-100 mb-2 mt-1">
<Logo size={42} className="-mt-1" />
<div>
Welcome to{" "}
<a
href="https://shadcnstore.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
ShadcnStore
</a>
</div>
<div>Emlak CRM&apos;e hoş geldiniz</div>
</h3>
<p className="text-sm text-muted-foreground dark:text-neutral-400 leading-relaxed">
Explore our premium Shadcn UI{" "}
<a
href="https://shadcnstore.com/blocks"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
blocks
</a>{" "}
to build your next project faster.
Portföy, müşteri ve finans modüllerini keşfedin. Yardıma ihtiyacınız olursa
Akademi bölümünden adım adım rehberlere ulaşabilirsiniz.
</p>
</div>
</CardContent>
+7 -14
View File
@@ -7,24 +7,17 @@ export function SiteFooter() {
<div className="px-4 py-4 lg:px-6">
<div className="text-muted-foreground flex flex-col items-center justify-between gap-2 text-xs sm:flex-row">
<p>
© {year} İşletmem bir{" "}
<Link
href="https://kovaksoft.com"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-primary font-medium transition-colors"
>
KovakSoft
</Link>{" "}
ürünüdür.
© {year}{" "}
<span className="text-foreground font-medium">Kovak Yazılım ve Medya LTD. Ş.</span>
{" "} Emlak CRM
</p>
<div className="flex items-center gap-3">
<Link href="#" className="hover:text-foreground transition-colors">
Kullanım şartları
<Link href="/kullanim-sartlari" className="hover:text-foreground transition-colors">
Kullanım Şartları
</Link>
<span aria-hidden>·</span>
<Link href="#" className="hover:text-foreground transition-colors">
Gizlilik
<Link href="/kvkk" className="hover:text-foreground transition-colors">
KVKK / Gizlilik
</Link>
</div>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { Building2 } from "lucide-react";
import { Buildings } from '@/lib/icons';
import { CommandSearch } from "@/components/command-search";
import { ModeToggle } from "@/components/mode-toggle";
@@ -22,7 +22,7 @@ export function SiteHeader({ company }: { company?: ShellCompany }) {
{company && (
<div className="text-muted-foreground hidden items-center gap-1.5 text-sm md:flex">
<Building2 className="size-3.5" />
<Buildings className="size-3.5" />
<span className="max-w-[260px] truncate">{company.name}</span>
</div>
)}
+4 -4
View File
@@ -1,7 +1,7 @@
"use client"
import React from 'react'
import { Layout, Palette, RotateCcw, Settings, X } from 'lucide-react'
import { Layout, Palette, ArrowCounterClockwise, GearSix, X } from '@/lib/icons'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -111,7 +111,7 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
<SheetHeader className="space-y-0 p-4 pb-2">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Settings className="h-4 w-4" />
<GearSix className="h-4 w-4" />
</div>
<SheetTitle className="text-lg font-semibold">Görünüm</SheetTitle>
<div className="ml-auto flex items-center gap-2">
@@ -123,7 +123,7 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
aria-label="Varsayılana dön"
title="Varsayılana dön"
>
<RotateCcw className="h-4 w-4" />
<ArrowCounterClockwise className="h-4 w-4" />
</Button>
<Button
variant="outline"
@@ -199,7 +199,7 @@ export function ThemeCustomizerTrigger({ onClick }: { onClick: () => void }) {
sidebarConfig.side === "left" ? "right-4" : "left-4"
)}
>
<Settings className="h-5 w-5" />
<GearSix className="h-5 w-5" />
</Button>
)
}
@@ -65,7 +65,7 @@ export function LayoutTab() {
"border-r"
}`}
>
{/* Menu icon representations - clearer and more visible */}
{/* List icon representations - clearer and more visible */}
<div className="h-0.5 w-full bg-foreground/60 rounded"></div>
<div className="h-0.5 w-3/4 bg-foreground/50 rounded"></div>
<div className="h-0.5 w-2/3 bg-foreground/40 rounded"></div>
@@ -1,6 +1,6 @@
"use client"
import { Dices, Upload, Sun, Moon } from 'lucide-react'
import { Shuffle, Upload, Sun, Moon } from '@/lib/icons'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -92,7 +92,7 @@ export function ThemeTab({
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Hazır temalar</Label>
<Button variant="outline" size="sm" onClick={handleRandomShadcn} className="cursor-pointer">
<Dices className="h-3.5 w-3.5 mr-1.5" />
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
Rastgele
</Button>
</div>
@@ -146,7 +146,7 @@ export function ThemeTab({
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Genişletilmiş temalar</Label>
<Button variant="outline" size="sm" onClick={handleRandomTweakcn} className="cursor-pointer">
<Dices className="h-3.5 w-3.5 mr-1.5" />
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
Rastgele
</Button>
</div>
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -0,0 +1,53 @@
"use client";
import { Loader2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DeleteConfirmDialogProps {
open: boolean;
onOpenChange: (v: boolean) => void;
title?: string;
description?: string;
isPending?: boolean;
onConfirm: () => void;
}
export function DeleteConfirmDialog({
open,
onOpenChange,
title = "Silmek istediğinizden emin misiniz?",
description = "Bu işlem geri alınamaz.",
isPending = false,
onConfirm,
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>İptal</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState, type ReactNode } from "react";
import { Check } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
// ─── ResponsiveSheet ────────────────────────────────────────────────────────
interface ResponsiveSheetProps {
open: boolean;
onOpenChange: (v: boolean) => void;
title: string;
maxWidth?: string;
children: ReactNode;
}
export function ResponsiveSheet({
open,
onOpenChange,
title,
maxWidth = "sm:max-w-xl",
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} direction="bottom">
<DrawerContent className="max-h-[92dvh]">
<DrawerHeader className="border-b pb-3">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">{children}</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn(maxWidth, "max-h-[90vh] overflow-y-auto")}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div>{children}</div>
</DialogContent>
</Dialog>
);
}
// ─── FormWizard ─────────────────────────────────────────────────────────────
export interface WizardStep {
label: string;
content: ReactNode;
}
interface FormWizardProps {
steps: WizardStep[];
isPending?: boolean;
submitLabel?: string;
}
export function FormWizard({ steps, isPending, submitLabel = "Kaydet" }: FormWizardProps) {
const [current, setCurrent] = useState(0);
const isFirst = current === 0;
const isLast = current === steps.length - 1;
return (
<div className="space-y-6">
{/* ── Adım göstergesi ── */}
<div data-tour="form-step-nav" className="flex items-start">
{steps.map((step, i) => (
<div key={i} className="contents">
<button
type="button"
data-tour={`form-circle-${i}`}
onClick={() => setCurrent(i)}
className="flex flex-col items-center gap-1.5 shrink-0"
>
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 transition-all",
i < current && "bg-primary border-primary text-primary-foreground",
i === current && "border-primary text-primary bg-primary/10",
i > current && "border-border text-muted-foreground",
)}
>
{i < current ? <Check className="size-3.5" /> : <span>{i + 1}</span>}
</div>
<span
className={cn(
"text-[10px] font-medium whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
)}
>
{step.label}
</span>
</button>
{i < steps.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mt-3.5 mx-2 rounded-full transition-colors",
i < current ? "bg-primary" : "bg-border",
)}
/>
)}
</div>
))}
</div>
{/* ── İçerik (aktif olmayan adımlar hidden — input'lar yine de submit edilir) ── */}
<div className="min-h-[180px]">
{steps.map((step, i) => (
<div key={i} data-tour={`form-step-content-${i}`} className={i === current ? "space-y-4" : "hidden"}>
{step.content}
</div>
))}
</div>
{/* ── Navigasyon ── */}
<div className={cn("flex gap-2", isFirst ? "justify-end" : "justify-between")}>
{!isFirst && (
<Button type="button" variant="outline" onClick={() => setCurrent((c) => c - 1)}>
Geri
</Button>
)}
{!isLast ? (
<Button key="next" type="button" onClick={() => setCurrent((c) => c + 1)}>
İleri
</Button>
) : (
<Button key="submit" type="submit" disabled={isPending}>
{isPending ? "Kaydediliyor…" : submitLabel}
</Button>
)}
</div>
</div>
);
}
// ─── FormSection (opsiyonel başlık, adım içinde kullanılabilir) ──────────────
export function FormSection({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</p>
{children}
</div>
);
}
+3 -3
View File
@@ -3,7 +3,7 @@
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"
import { Rocket, Blocks, LayoutDashboard, ArrowRight } from "lucide-react"
import { Rocket, SquaresFour, ArrowRight } from '@/lib/icons'
import Image from "next/image"
@@ -42,7 +42,7 @@ export function UpgradeToProButton() {
<div className="relative w-1/2">
<a href={SHADCN_BLOCKS_URL} target="_blank" rel="noopener noreferrer">
<Button className="w-full flex items-center justify-center cursor-pointer" variant="default">
<Blocks size={16} />
<SquaresFour size={16} />
Pro Blocks
<ArrowRight size={16} />
</Button>
@@ -50,7 +50,7 @@ export function UpgradeToProButton() {
</div>
<div className="relative w-1/2">
<Button className="w-full flex items-center justify-center" variant="default" disabled>
<LayoutDashboard size={16} />
<SquaresFour size={16} />
Pro Dashboards
</Button>
<span className="absolute -top-5 -right-1">