From 4ef0482732a667af65bc13b590419342e168bc29 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Tue, 5 May 2026 12:03:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20all=20core=20modules=20=E2=80=94=20prop?= =?UTF-8?q?erties,=20customers,=20searches,=20matches,=20presentations,=20?= =?UTF-8?q?activities,=20investors=20+=20public=20sunum=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server actions: property/customer/search/presentation/activity/investor CRUD - Matching engine: matchPropertyToSearches + syncMatchesForSearch on search save - UI: form sheets + table clients for all modules - Public /sunum/[token] page (no auth) with property card grid + expiry check - All pages force-dynamic for auth guard compatibility --- src/app/(dashboard)/activities/page.tsx | 41 +++- .../(dashboard)/customers/matches/page.tsx | 81 +++++++- src/app/(dashboard)/customers/page.tsx | 16 +- .../(dashboard)/customers/searches/page.tsx | 35 +++- src/app/(dashboard)/investors/page.tsx | 31 ++- src/app/(dashboard)/presentations/page.tsx | 41 +++- src/app/(dashboard)/properties/page.tsx | 16 +- src/app/sunum/[token]/page.tsx | 138 +++++++++++++ .../activities/activities-client.tsx | 175 ++++++++++++++++ .../activities/activity-form-sheet.tsx | 141 +++++++++++++ .../customers/customer-form-sheet.tsx | 101 ++++++++++ src/components/customers/customers-client.tsx | 124 ++++++++++++ .../customers/search-form-sheet.tsx | 190 ++++++++++++++++++ src/components/customers/searches-client.tsx | 171 ++++++++++++++++ .../investors/investor-form-sheet.tsx | 109 ++++++++++ src/components/investors/investors-client.tsx | 120 +++++++++++ .../presentations/presentation-form-sheet.tsx | 160 +++++++++++++++ .../presentations/presentations-client.tsx | 164 +++++++++++++++ .../properties/properties-client.tsx | 153 ++++++++++++++ .../properties/property-form-sheet.tsx | 181 +++++++++++++++++ src/lib/appwrite/activity-actions.ts | 117 +++++++++++ src/lib/appwrite/customer-actions.ts | 99 +++++++++ src/lib/appwrite/customer-queries.ts | 23 +++ src/lib/appwrite/customer-search-actions.ts | 160 +++++++++++++++ src/lib/appwrite/investor-actions.ts | 128 ++++++++++++ src/lib/appwrite/matching.ts | 117 +++++++++++ src/lib/appwrite/presentation-actions.ts | 119 +++++++++++ src/lib/appwrite/property-actions.ts | 143 +++++++++++++ src/lib/appwrite/property-queries.ts | 23 +++ src/lib/validation/activities.ts | 12 ++ src/lib/validation/customer-searches.ts | 18 ++ src/lib/validation/customers.ts | 24 ++- src/lib/validation/presentations.ts | 11 + src/lib/validation/properties.ts | 28 +++ 34 files changed, 3174 insertions(+), 36 deletions(-) create mode 100644 src/app/sunum/[token]/page.tsx create mode 100644 src/components/activities/activities-client.tsx create mode 100644 src/components/activities/activity-form-sheet.tsx create mode 100644 src/components/customers/customer-form-sheet.tsx create mode 100644 src/components/customers/customers-client.tsx create mode 100644 src/components/customers/search-form-sheet.tsx create mode 100644 src/components/customers/searches-client.tsx create mode 100644 src/components/investors/investor-form-sheet.tsx create mode 100644 src/components/investors/investors-client.tsx create mode 100644 src/components/presentations/presentation-form-sheet.tsx create mode 100644 src/components/presentations/presentations-client.tsx create mode 100644 src/components/properties/properties-client.tsx create mode 100644 src/components/properties/property-form-sheet.tsx create mode 100644 src/lib/appwrite/activity-actions.ts create mode 100644 src/lib/appwrite/customer-actions.ts create mode 100644 src/lib/appwrite/customer-queries.ts create mode 100644 src/lib/appwrite/customer-search-actions.ts create mode 100644 src/lib/appwrite/investor-actions.ts create mode 100644 src/lib/appwrite/matching.ts create mode 100644 src/lib/appwrite/presentation-actions.ts create mode 100644 src/lib/appwrite/property-actions.ts create mode 100644 src/lib/appwrite/property-queries.ts create mode 100644 src/lib/validation/activities.ts create mode 100644 src/lib/validation/customer-searches.ts create mode 100644 src/lib/validation/presentations.ts create mode 100644 src/lib/validation/properties.ts diff --git a/src/app/(dashboard)/activities/page.tsx b/src/app/(dashboard)/activities/page.tsx index e2eae81..04f06ef 100644 --- a/src/app/(dashboard)/activities/page.tsx +++ b/src/app/(dashboard)/activities/page.tsx @@ -1,8 +1,41 @@ -export default function Page() { +export const dynamic = "force-dynamic"; + +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { listProperties } from "@/lib/appwrite/property-queries"; +import { DATABASE_ID, TABLES, type Activity } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { ActivitiesClient } from "@/components/activities/activities-client"; + +export default async function ActivitiesPage() { + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + const [customers, properties, activitiesResult] = await Promise.all([ + listCustomers(ctx.tenantId), + listProperties(ctx.tenantId), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.activities, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(300), + ], + }), + ]); + + const activities = activitiesResult.rows as unknown as Activity[]; + return ( -
-

activities

-

Yakında...

+
+
); } diff --git a/src/app/(dashboard)/customers/matches/page.tsx b/src/app/(dashboard)/customers/matches/page.tsx index 89182dc..f43d955 100644 --- a/src/app/(dashboard)/customers/matches/page.tsx +++ b/src/app/(dashboard)/customers/matches/page.tsx @@ -1,8 +1,81 @@ -export default function MatchesPage() { +export const dynamic = "force-dynamic"; + +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { listProperties } from "@/lib/appwrite/property-queries"; +import { DATABASE_ID, TABLES, type PropertyMatch } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; + +export default async function MatchesPage() { + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + const [customers, properties, matchesResult] = await Promise.all([ + listCustomers(ctx.tenantId), + listProperties(ctx.tenantId), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.propertyMatches, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(500), + ], + }), + ]); + + const matches = matchesResult.rows as unknown as PropertyMatch[]; + const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name])); + const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p.title])); + return ( -
-

Eşleşmeler

-

Yakında...

+
+
+

Eşleşmeler

+ {matches.length} eşleşme +
+ +
+ + + + + + + + + + + {matches.length === 0 && ( + + + + )} + {matches.map((m) => ( + + + + + + + ))} + +
MüşteriİlanTarihGörüntülendi
+ Henüz eşleşme yok. +
{customerMap[m.customerId] ?? m.customerId}{propertyMap[m.propertyId] ?? m.propertyId} + {new Date(m.$createdAt).toLocaleDateString("tr-TR")} + + {m.viewedAt ? ( + + {new Date(m.viewedAt).toLocaleDateString("tr-TR")} + + ) : ( + Hayır + )} +
+
); } diff --git a/src/app/(dashboard)/customers/page.tsx b/src/app/(dashboard)/customers/page.tsx index bd90174..f54db99 100644 --- a/src/app/(dashboard)/customers/page.tsx +++ b/src/app/(dashboard)/customers/page.tsx @@ -1,8 +1,16 @@ -export default function CustomersPage() { +export const dynamic = "force-dynamic"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { CustomersClient } from "@/components/customers/customers-client"; + +export default async function CustomersPage() { + const ctx = await requireTenant(); + const customers = await listCustomers(ctx.tenantId); + return ( -
-

Müşteriler

-

Yakında...

+
+
); } diff --git a/src/app/(dashboard)/customers/searches/page.tsx b/src/app/(dashboard)/customers/searches/page.tsx index 369a8e9..8da4e33 100644 --- a/src/app/(dashboard)/customers/searches/page.tsx +++ b/src/app/(dashboard)/customers/searches/page.tsx @@ -1,8 +1,35 @@ -export default function SearchesPage() { +export const dynamic = "force-dynamic"; + +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { DATABASE_ID, TABLES, type CustomerSearch } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { SearchesClient } from "@/components/customers/searches-client"; + +export default async function SearchesPage() { + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + const [customers, searchesResult] = await Promise.all([ + listCustomers(ctx.tenantId), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.customerSearches, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(500), + ], + }), + ]); + + const searches = searchesResult.rows as unknown as CustomerSearch[]; + return ( -
-

Arama Kriterleri

-

Yakında...

+
+
); } diff --git a/src/app/(dashboard)/investors/page.tsx b/src/app/(dashboard)/investors/page.tsx index 85a06e5..778b2e5 100644 --- a/src/app/(dashboard)/investors/page.tsx +++ b/src/app/(dashboard)/investors/page.tsx @@ -1,8 +1,31 @@ -export default function Page() { +export const dynamic = "force-dynamic"; + +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { DATABASE_ID, TABLES, type Investor } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { InvestorsClient } from "@/components/investors/investors-client"; + +export default async function InvestorsPage() { + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.investors, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(200), + ], + }); + + const investors = result.rows as unknown as Investor[]; + return ( -
-

investors

-

Yakında...

+
+
); } diff --git a/src/app/(dashboard)/presentations/page.tsx b/src/app/(dashboard)/presentations/page.tsx index b0ff874..5c07b96 100644 --- a/src/app/(dashboard)/presentations/page.tsx +++ b/src/app/(dashboard)/presentations/page.tsx @@ -1,8 +1,41 @@ -export default function Page() { +export const dynamic = "force-dynamic"; + +import { Query } from "node-appwrite"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { listProperties } from "@/lib/appwrite/property-queries"; +import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { PresentationsClient } from "@/components/presentations/presentations-client"; + +export default async function PresentationsPage() { + const ctx = await requireTenant(); + const { tablesDB } = createAdminClient(); + + const [customers, properties, presResult] = await Promise.all([ + listCustomers(ctx.tenantId), + listProperties(ctx.tenantId), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.presentations, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.orderDesc("$createdAt"), + Query.limit(200), + ], + }), + ]); + + const presentations = presResult.rows as unknown as Presentation[]; + return ( -
-

presentations

-

Yakında...

+
+
); } diff --git a/src/app/(dashboard)/properties/page.tsx b/src/app/(dashboard)/properties/page.tsx index 6207c5b..a129b1b 100644 --- a/src/app/(dashboard)/properties/page.tsx +++ b/src/app/(dashboard)/properties/page.tsx @@ -1,8 +1,16 @@ -export default function Page() { +export const dynamic = "force-dynamic"; + +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { listProperties } from "@/lib/appwrite/property-queries"; +import { PropertiesClient } from "@/components/properties/properties-client"; + +export default async function PropertiesPage() { + const ctx = await requireTenant(); + const properties = await listProperties(ctx.tenantId); + return ( -
-

properties

-

Yakında...

+
+
); } diff --git a/src/app/sunum/[token]/page.tsx b/src/app/sunum/[token]/page.tsx new file mode 100644 index 0000000..691b726 --- /dev/null +++ b/src/app/sunum/[token]/page.tsx @@ -0,0 +1,138 @@ +import { notFound } from "next/navigation"; +import { Query } from "node-appwrite"; + +import { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { incrementPresentationViewCount } from "@/lib/appwrite/presentation-actions"; +import { + PROPERTY_TYPE_LABELS, + LISTING_TYPE_LABELS, + PROPERTY_STATUS_LABELS, +} from "@/lib/appwrite/schema"; + +interface Props { + params: Promise<{ token: string }>; +} + +export default async function SunumPage({ params }: Props) { + const { token } = await params; + const { tablesDB } = createAdminClient(); + + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.presentations, + queries: [Query.equal("shareToken", token), Query.limit(1)], + }); + + if (!result.rows.length) notFound(); + + const presentation = result.rows[0] as unknown as Presentation; + + if (presentation.expiresAt && new Date(presentation.expiresAt) < new Date()) { + return ( +
+
+

Sunum Süresi Doldu

+

Bu sunum artık geçerli değil.

+
+
+ ); + } + + // Increment view count (fire-and-forget) + void incrementPresentationViewCount(presentation.$id, presentation.viewCount ?? 0); + + let propertyIds: string[] = []; + try { + propertyIds = JSON.parse(presentation.propertyIds) as string[]; + } catch { + propertyIds = []; + } + + const properties: Property[] = []; + for (const pid of propertyIds) { + try { + const row = await tablesDB.getRow(DATABASE_ID, TABLES.properties, pid); + properties.push(row as unknown as Property); + } catch { + // Property may have been deleted + } + } + + return ( +
+
+

{presentation.title}

+ {presentation.notes && ( +

{presentation.notes}

+ )} +

{properties.length} ilan

+
+ +
+ {properties.map((p) => ( + + ))} + {properties.length === 0 && ( +

+ Bu sunumda ilan bulunmuyor. +

+ )} +
+
+ ); +} + +function PropertyCard({ property: p }: { property: Property }) { + const isExpired = p.status === "satildi" || p.status === "kiralandit"; + + return ( +
+
+ 🏠 +
+
+
+

{p.title}

+ + {PROPERTY_STATUS_LABELS[p.status] ?? p.status} + +
+ +
+ {PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType} + · + {LISTING_TYPE_LABELS[p.listingType] ?? p.listingType} + {p.roomCount && ( + <> + · + {p.roomCount} + + )} + {p.netM2 && ( + <> + · + {p.netM2} m² + + )} +
+ +

+ {[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")} +

+ +

+ {p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"} +

+ + {p.description && ( +

{p.description}

+ )} +
+
+ ); +} diff --git a/src/components/activities/activities-client.tsx b/src/components/activities/activities-client.tsx new file mode 100644 index 0000000..73b351b --- /dev/null +++ b/src/components/activities/activities-client.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal, Plus, Pencil, Trash2, CheckCircle } from "lucide-react"; +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, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + completeActivityAction, + deleteActivityAction, +} from "@/lib/appwrite/activity-actions"; +import { ActivityFormSheet } from "./activity-form-sheet"; +import type { Activity, Customer, Property } from "@/lib/appwrite/schema"; +import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema"; + +interface ActivitiesClientProps { + initialActivities: Activity[]; + customers: Customer[]; + properties: Property[]; +} + +export function ActivitiesClient({ + initialActivities, + customers, + properties, +}: ActivitiesClientProps) { + const [activities, setActivities] = useState(initialActivities); + const [sheetOpen, setSheetOpen] = useState(false); + const [editing, setEditing] = useState(null); + + function customerName(id?: string | null) { + if (!id) return "—"; + return customers.find((c) => c.$id === id)?.name ?? "—"; + } + + function propertyTitle(id?: string | null) { + if (!id) return "—"; + return properties.find((p) => p.$id === id)?.title ?? "—"; + } + + 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); + if (result.ok) { + setActivities((prev) => + prev.map((x) => x.$id === a.$id ? { ...x, completedAt: new Date().toISOString() } : x), + ); + toast.success("Aktivite tamamlandı."); + } else { + toast.error(result.error ?? "Tamamlanamadı."); + } + } + + async function handleDelete(a: Activity) { + if (!confirm("Bu aktivite silinsin mi?")) return; + const result = await deleteActivityAction(a.$id); + if (result.ok) { + setActivities((prev) => prev.filter((x) => x.$id !== a.$id)); + toast.success("Aktivite silindi."); + } else { + toast.error(result.error ?? "Silinemedi."); + } + } + + return ( +
+
+

Aktiviteler

+ +
+ +
+ + + + Tip + Başlık + Müşteri + İlan + Tarih + Durum + + + + + {activities.length === 0 && ( + + + Henüz aktivite yok. + + + )} + {activities.map((a) => ( + + + + {ACTIVITY_TYPE_LABELS[a.type] ?? a.type} + + + {a.title} + {customerName(a.customerId)} + {propertyTitle(a.propertyId)} + + {a.dueDate ? new Date(a.dueDate).toLocaleDateString("tr-TR") : "—"} + + + {a.completedAt ? ( + Tamamlandı + ) : ( + Açık + )} + + + + + + + + {!a.completedAt && ( + handleComplete(a)}> + + Tamamla + + )} + openEdit(a)}> + + Düzenle + + handleDelete(a)} + className="text-destructive focus:text-destructive" + > + + Sil + + + + + + ))} + +
+
+ + +
+ ); +} diff --git a/src/components/activities/activity-form-sheet.tsx b/src/components/activities/activity-form-sheet.tsx new file mode 100644 index 0000000..26b63ab --- /dev/null +++ b/src/components/activities/activity-form-sheet.tsx @@ -0,0 +1,141 @@ +"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 { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions"; +import type { Activity, Customer, Property } from "@/lib/appwrite/schema"; + +type ActionState = { ok: boolean; error?: string; fieldErrors?: Record }; +const INITIAL: ActionState = { ok: false }; + +interface ActivityFormSheetProps { + open: boolean; + onOpenChange: (v: boolean) => void; + activity?: Activity | null; + customers: Customer[]; + properties: Property[]; + onSuccess?: () => void; +} + +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(() => { + if (state.ok) { + toast.success(activity ? "Aktivite güncellendi." : "Aktivite oluşturuldu."); + onSuccess?.(); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + }, [state]); + + const fe = state.fieldErrors ?? {}; + + return ( + + + + {activity ? "Aktiviteyi Düzenle" : "Yeni Aktivite"} + + +
+
+ + +
+ +
+ + + {fe.title &&

{fe.title[0]}

} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +