diff --git a/app/(dashboard)/chat/page.tsx b/app/(dashboard)/chat/page.tsx
index 04ccea7..3badbd4 100644
--- a/app/(dashboard)/chat/page.tsx
+++ b/app/(dashboard)/chat/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from "react";
import { Brain, Send, MessageSquare, Plus, Trash2, Loader2, Wrench } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { Button } from "poyraz-ui/atoms";
-import { useChat } from "ai/react";
+import { useChat } from "@ai-sdk/react";
interface ChatSession {
id: string;
@@ -107,10 +107,10 @@ export default function AIChatPage() {
const customHandleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!input.trim() || isLoading) return;
+ if (!(input || "").trim() || isLoading) return;
let sessionId = activeSessionId;
- const currentInput = input;
+ const currentInput = input || "";
setInput("");
if (!sessionId) {
@@ -309,8 +309,8 @@ export default function AIChatPage() {
diff --git a/app/(dashboard)/clients/[id]/actions.ts b/app/(dashboard)/clients/[id]/actions.ts
new file mode 100644
index 0000000..374eefc
--- /dev/null
+++ b/app/(dashboard)/clients/[id]/actions.ts
@@ -0,0 +1,49 @@
+"use server";
+
+import { createClient } from "@/lib/supabase/server";
+import { revalidatePath } from "next/cache";
+
+function cleanText(value: FormDataEntryValue | null) {
+ const text = typeof value === "string" ? value.trim() : "";
+ return text.length > 0 ? text : null;
+}
+
+export async function addClientActivity(clientId: string, formData: FormData) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ error: userError,
+ } = await supabase.auth.getUser();
+
+ if (userError || !user) {
+ throw new Error("Kullanıcı bulunamadı.");
+ }
+
+ const title = cleanText(formData.get("title"));
+ if (!title) {
+ throw new Error("Aktivite başlığı zorunludur.");
+ }
+
+ const { error } = await supabase.from("client_activities").insert({
+ user_id: user.id,
+ client_id: clientId,
+ type: formData.get("type") as string || "note",
+ title,
+ content: cleanText(formData.get("content")),
+ activity_date: formData.get("activity_date") as string || new Date().toISOString(),
+ });
+
+ if (error) {
+ throw new Error(`Aktivite eklenemedi: ${error.message}`);
+ }
+
+ // Update client's last_contact_date
+ await supabase
+ .from("clients")
+ .update({ last_contact_date: new Date().toISOString() })
+ .eq("id", clientId)
+ .eq("user_id", user.id);
+
+ revalidatePath(`/clients/${clientId}`);
+ revalidatePath(`/clients`);
+}
diff --git a/app/(dashboard)/clients/[id]/client-detail-client.tsx b/app/(dashboard)/clients/[id]/client-detail-client.tsx
new file mode 100644
index 0000000..00fdf30
--- /dev/null
+++ b/app/(dashboard)/clients/[id]/client-detail-client.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { useState } from "react";
+import { format } from "date-fns";
+import { tr } from "date-fns/locale";
+import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "poyraz-ui/molecules";
+import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText } from "lucide-react";
+import Link from "next/link";
+import { addClientActivity } from "./actions";
+
+export type ClientDetailData = {
+ id: string;
+ name: string;
+ company_name: string | null;
+ email: string | null;
+ phone: string | null;
+ website: string | null;
+ pipeline_stage: string;
+ status: string;
+ notes: string | null;
+};
+
+export type ClientActivity = {
+ id: string;
+ type: "note" | "call" | "meeting" | "email";
+ title: string;
+ content: string | null;
+ activity_date: string;
+ created_at: string;
+};
+
+export function ClientDetailClient({ client, activities }: { client: ClientDetailData; activities: ClientActivity[] }) {
+ const [isAddingActivity, setIsAddingActivity] = useState(false);
+ const [openDialog, setOpenDialog] = useState(false);
+
+ const getActivityIcon = (type: string) => {
+ switch (type) {
+ case "call": return ;
+ case "meeting": return ;
+ case "email": return ;
+ default: return ;
+ }
+ };
+
+ const getActivityBadge = (type: string) => {
+ switch (type) {
+ case "call": return Arama ;
+ case "meeting": return Toplantı ;
+ case "email": return E-posta ;
+ default: return Not ;
+ }
+ };
+
+ async function handleAddActivity(formData: FormData) {
+ setIsAddingActivity(true);
+ try {
+ await addClientActivity(client.id, formData);
+ setOpenDialog(false);
+ } finally {
+ setIsAddingActivity(false);
+ }
+ }
+
+ return (
+
+ {/* Header Info */}
+
+
+
+ {client.name.split(" ").slice(0, 2).map(n => n[0]?.toUpperCase()).join("")}
+
+
+
{client.name}
+ {client.company_name &&
{client.company_name}
}
+
+
+
+ {client.status}
+
+ {client.pipeline_stage.replace('_', ' ')}
+
+
+
+
+
+ {/* Left Column: Contact & Details */}
+
+
+
+ İletişim Bilgileri
+
+ {client.email ? (
+
+ ) : null}
+ {client.phone ? (
+
+ ) : null}
+ {client.website ? (
+
+ ) : null}
+ {!client.email && !client.phone && !client.website && (
+
İletişim bilgisi girilmemiş.
+ )}
+
+
+
+
+
+
+ Genel Notlar
+ {client.notes ? (
+ {client.notes}
+ ) : (
+ Müşteriye ait genel not bulunmuyor.
+ )}
+
+
+
+
+ {/* Right Column: Activities & Timeline */}
+
+
+
+
+
Aktivite Geçmişi
+
+
+
+
+ Aktivite Ekle
+
+
+
+
+
+
+
+
+
+ {activities.length === 0 ? (
+
+
Henüz kaydedilmiş bir aktivite yok.
+
+ ) : (
+ activities.map((activity) => (
+
+ {/* Icon */}
+
+ {getActivityIcon(activity.type)}
+
+ {/* Card */}
+
+
+
+
{activity.title}
+ {getActivityBadge(activity.type)}
+
+
+ {format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: tr })}
+
+ {activity.content && (
+ {activity.content}
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/(dashboard)/clients/[id]/page.tsx b/app/(dashboard)/clients/[id]/page.tsx
new file mode 100644
index 0000000..606e9d4
--- /dev/null
+++ b/app/(dashboard)/clients/[id]/page.tsx
@@ -0,0 +1,34 @@
+import { createClient } from "@/lib/supabase/server";
+import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client";
+import { notFound } from "next/navigation";
+
+export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) return null;
+
+ const { data: clientData, error } = await supabase
+ .from("clients")
+ .select("id, name, company_name, email, phone, website, pipeline_stage, status, notes")
+ .eq("id", id)
+ .eq("user_id", user.id)
+ .single();
+
+ if (error || !clientData) {
+ notFound();
+ }
+
+ const { data: activitiesData } = await supabase
+ .from("client_activities")
+ .select("id, type, title, content, activity_date, created_at")
+ .eq("client_id", id)
+ .eq("user_id", user.id)
+ .order("activity_date", { ascending: false });
+
+ const client: ClientDetailData = clientData as ClientDetailData;
+ const activities: ClientActivity[] = (activitiesData || []) as ClientActivity[];
+
+ return ;
+}
diff --git a/app/(dashboard)/clients/actions.ts b/app/(dashboard)/clients/actions.ts
index 3701c22..fb12af3 100644
--- a/app/(dashboard)/clients/actions.ts
+++ b/app/(dashboard)/clients/actions.ts
@@ -58,6 +58,8 @@ export async function createClientRecord(formData: FormData) {
website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")),
notes: cleanText(formData.get("notes")),
+ pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead",
+ next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null,
});
if (error) {
@@ -86,6 +88,8 @@ export async function updateClientRecord(formData: FormData) {
website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")),
notes: cleanText(formData.get("notes")),
+ pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead",
+ next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null,
})
.eq("id", id)
.eq("user_id", userId);
diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx
index 2cf865b..073ec3d 100644
--- a/app/(dashboard)/clients/clients-client.tsx
+++ b/app/(dashboard)/clients/clients-client.tsx
@@ -19,6 +19,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
} from "poyraz-ui/molecules";
import {
Archive,
@@ -31,10 +35,14 @@ import {
UserCheck,
Users,
Wallet,
+ Clock,
+ ArrowRight,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
+import { format, isPast, isToday } from "date-fns";
+import { tr } from "date-fns/locale";
export type ClientListItem = {
id: string;
@@ -48,6 +56,11 @@ export type ClientListItem = {
created_at: string;
projectCount: number;
revenueTotal: number;
+ // CRM fields
+ pipeline_stage: "lead" | "contacted" | "proposal_sent" | "won" | "lost";
+ next_follow_up_date: string | null;
+ last_contact_date: string | null;
+ client_value_score: number;
};
const statusLabels = {
@@ -62,6 +75,14 @@ const statusClasses = {
archived: "border-zinc-200 bg-zinc-50 text-zinc-600",
};
+const pipelineStages = [
+ { id: "lead", label: "Potansiyel (Lead)", color: "border-slate-200 bg-slate-50 text-slate-700" },
+ { id: "contacted", label: "İletişime Geçildi", color: "border-blue-200 bg-blue-50 text-blue-700" },
+ { id: "proposal_sent", label: "Teklif İletildi", color: "border-amber-200 bg-amber-50 text-amber-700" },
+ { id: "won", label: "Kazanıldı (Won)", color: "border-emerald-200 bg-emerald-50 text-emerald-700" },
+ { id: "lost", label: "Kaybedildi (Lost)", color: "border-rose-200 bg-rose-50 text-rose-700" },
+];
+
type ClientsClientProps = {
clients: ClientListItem[];
totalRevenue: number;
@@ -79,6 +100,7 @@ export function ClientsClient({
}: ClientsClientProps) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
+
const filteredClients = normalizedQuery
? clients.filter((client) =>
[
@@ -95,20 +117,19 @@ export function ClientsClient({
: clients;
return (
-
+
- Freelancer operasyonu
+ CRM & Operasyon
- Müşteriler
+ CRM & Müşteriler
- Çalıştığın müşterileri, iletişim bilgilerini ve temel iş durumunu tek
- ekrandan yönet.
+ Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet.
@@ -118,25 +139,25 @@ export function ClientsClient({
c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
+ icon={Users}
+ iconClassName="bg-blue-50 text-blue-700"
+ />
+
c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()}
+ icon={Clock}
+ iconClassName="bg-rose-50 text-rose-700"
/>
-
-
-
-
-
-
- Müşteri listesi
-
-
- {filteredClients.length} kayıt görüntüleniyor.
-
-
-
setQuery(event.target.value)}
- placeholder="Müşteri, firma, e-posta veya not ara"
- className="md:max-w-sm"
- />
-
+
+
+
+ Pipeline (Kanban)
+ Müşteri Listesi
+
+
+ setQuery(event.target.value)}
+ placeholder="Müşteri, firma, e-posta veya not ara"
+ className="md:max-w-sm"
+ />
+
- {filteredClients.length > 0 ? (
-
-
- Müşteri
- İletişim
- Durum
- Projeler
- İşlem
-
-
- {filteredClients.map((client) => (
-
- ))}
-
-
- ) : (
-
- )}
-
-
+
+
+ {pipelineStages.map(stage => {
+ const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
+ return (
+
+
+
+
+ {stage.label}
+
+ {stageClients.length}
+
+
+ {stageClients.map(client => (
+
+
+
+
+ {client.name}
+
+
} />
+
+ {client.company_name && {client.company_name}
}
+
+ {client.next_follow_up_date && (
+
+
+
+ {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
+
+
+ )}
+
+
+ ))}
+ {stageClients.length === 0 && (
+
+ Boş
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {filteredClients.length > 0 ? (
+
+
+ Müşteri
+ İletişim
+ Aşama
+ Follow-up
+ Projeler
+ İşlem
+
+
+ {filteredClients.map((client) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+
);
}
function ClientRow({ client }: { client: ClientListItem }) {
+ const isFollowUpOverdue = client.next_follow_up_date && (isPast(new Date(client.next_follow_up_date)) || isToday(new Date(client.next_follow_up_date)));
+ const stage = pipelineStages.find(s => s.id === client.pipeline_stage) || pipelineStages[0];
+
return (
-
+
{getInitials(client.name)}
-
{client.name}
+
{client.name}
{client.company_name || "Firma bilgisi yok"}
@@ -217,43 +291,40 @@ function ClientRow({ client }: { client: ClientListItem }) {
{client.phone}
) : null}
- {client.website ? (
-
-
-
{client.website.replace(/^https?:\/\//, "")}
-
- ) : null}
{!client.email && !client.phone && !client.website ? (
İletişim bilgisi yok
) : null}
-
- {statusLabels[client.status]}
+
+ {stage.label}
-
{client.projectCount}
+ {client.next_follow_up_date ? (
+
+
+ {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
+
+ ) : (
+
-
+ )}
+
+
+
+
{client.projectCount} Proje
{formatCurrency(client.revenueTotal)}
-
- {client.status !== "archived" ? (
-
-
-
-
- Arşivle
-
-
- ) : null}
+
+
+
+
+
+
Düzenle} />
);
@@ -262,9 +333,11 @@ function ClientRow({ client }: { client: ClientListItem }) {
function ClientDialog({
mode,
client,
+ trigger
}: {
mode: "create" | "edit";
client?: ClientListItem;
+ trigger?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -284,15 +357,17 @@ function ClientDialog({
return (
-
- {mode === "create" ? : }
- {mode === "create" ? "Müşteri ekle" : "Düzenle"}
-
+ {trigger || (
+
+ {mode === "create" ? : }
+ {mode === "create" ? "Müşteri ekle" : "Düzenle"}
+
+ )}
-
+
{client ? : null}
@@ -300,8 +375,7 @@ function ClientDialog({
{mode === "create" ? "Yeni müşteri" : "Müşteriyi düzenle"}
- Müşteri bilgilerini sade tut; proje ve finans bağlantıları sonraki
- modüllerden otomatik görünecek.
+ Müşterinin iletişim ve CRM detaylarını girin.
@@ -326,28 +400,70 @@ function ClientDialog({
function ClientFormFields({ client }: { client?: ClientListItem }) {
return (
-
-
Müşteri adı
-
+
-
-
Firma / marka adı
-
+
+
+ Satış Aşaması (Pipeline)
+
+
+
+
+
+ {pipelineStages.map(stage => (
+ {stage.label}
+ ))}
+
+
+
+
+ Durum
+
+
+
+
+
+ Aktif
+ Duraklatıldı
+ Arşivlendi
+
+
+
+
+ Sonraki Follow-up Tarihi
+
+
+
+
+
E-posta
-
-
- Web sitesi
-
-
-
- Durum
-
-
-
-
-
- Aktif
- Duraklatıldı
- Arşivlendi
-
-
-
-
-
- Notlar
+ Genel Notlar
);
}
-function PhoneInput({
- id,
- name,
- defaultValue,
-}: {
- id: string;
- name: string;
- defaultValue: string;
-}) {
+function PhoneInput({ id, name, defaultValue }: { id: string; name: string; defaultValue: string; }) {
const [value, setValue] = useState(defaultValue);
-
return (
setValue(event.target.value.replace(/\s/g, ""))}
- onBlur={() => setValue(normalizeWebsite(value))}
- />
- );
-}
-
-function StatCard({
- label,
- value,
- description,
- icon: Icon,
- iconClassName,
-}: {
- label: string;
- value: string;
- description?: string;
- icon: LucideIcon;
- iconClassName: string;
-}) {
+function StatCard({ label, value, description, icon: Icon, iconClassName }: { label: string; value: string; description?: string; icon: LucideIcon; iconClassName: string; }) {
return (
@@ -473,9 +520,7 @@ function StatCard({
{label}
{value}
- {description ? (
-
{description}
- ) : null}
+ {description ?
{description}
: null}
@@ -494,73 +539,33 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
{hasQuery ? "Aramana uygun müşteri yok" : "Henüz müşteri eklenmedi"}
- {hasQuery
- ? "Arama metnini sadeleştirerek tekrar deneyebilirsin."
- : "İlk müşterini ekleyerek proje, görev ve finans kayıtlarını müşteriyle ilişkilendirmeye başlayabilirsin."}
+ İlk müşterini ekleyerek potansiyel satışlarını takip etmeye başla.
);
}
function getInitials(name: string) {
- return name
- .split(" ")
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase())
- .join("");
+ return name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join("");
}
function formatPhone(input: string) {
const digits = input.replace(/\D/g, "");
-
- if (!digits) {
- return "";
- }
-
- const local = digits.startsWith("90")
- ? digits.slice(2, 12)
- : digits.startsWith("0")
- ? digits.slice(1, 11)
- : digits.slice(0, 10);
-
+ if (!digits) return "";
+ const local = digits.startsWith("90") ? digits.slice(2, 12) : digits.startsWith("0") ? digits.slice(1, 11) : digits.slice(0, 10);
const area = local.slice(0, 3);
const first = local.slice(3, 6);
const second = local.slice(6, 8);
const third = local.slice(8, 10);
-
let formatted = "+90";
if (area) formatted += ` (${area}`;
if (area.length === 3) formatted += ")";
if (first) formatted += ` ${first}`;
if (second) formatted += ` ${second}`;
if (third) formatted += ` ${third}`;
-
return formatted;
}
-function normalizeWebsite(input: string) {
- const value = input.trim().replace(/\s/g, "");
-
- if (!value) {
- return "";
- }
-
- if (/^https?:\/\//i.test(value)) {
- return value;
- }
-
- return `https://${value}`;
-}
-
-function getWebsiteHref(input: string) {
- return /^https?:\/\//i.test(input) ? input : `https://${input}`;
-}
-
function formatCurrency(value: number) {
- return new Intl.NumberFormat("tr-TR", {
- style: "currency",
- currency: "USD",
- maximumFractionDigits: 0,
- }).format(value);
+ return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
}
diff --git a/app/(dashboard)/clients/page.tsx b/app/(dashboard)/clients/page.tsx
index f2b972d..a72c8a8 100644
--- a/app/(dashboard)/clients/page.tsx
+++ b/app/(dashboard)/clients/page.tsx
@@ -10,6 +10,10 @@ type ClientRow = {
website: string | null;
status: "active" | "paused" | "archived";
notes: string | null;
+ pipeline_stage: "lead" | "contacted" | "proposal_sent" | "won" | "lost";
+ next_follow_up_date: string | null;
+ last_contact_date: string | null;
+ client_value_score: number;
created_at: string;
};
@@ -38,7 +42,7 @@ export default async function ClientsPage() {
await Promise.all([
supabase
.from("clients")
- .select("id, name, company_name, email, phone, website, status, notes, created_at")
+ .select("id, name, company_name, email, phone, website, status, notes, created_at, pipeline_stage, next_follow_up_date, last_contact_date, client_value_score")
.eq("user_id", user.id)
.order("created_at", { ascending: false }),
supabase.from("projects").select("client_id").eq("user_id", user.id),
diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx
index 4253cf3..bbb9a6d 100644
--- a/app/(dashboard)/finance/finance-client.tsx
+++ b/app/(dashboard)/finance/finance-client.tsx
@@ -27,6 +27,8 @@ import {
Plus,
Trash2,
Wallet,
+ Brain,
+ Loader2,
} from "lucide-react";
import { useMemo, useState } from "react";
@@ -125,9 +127,10 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et.
+
-
-
@@ -538,6 +541,85 @@ function calculateSummary(transactions: FinanceTransactionItem[]) {
);
}
+function AIFinanceDialog() {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState
(null);
+
+ const handleAnalyze = async () => {
+ setLoading(true);
+ setResult(null);
+ try {
+ const res = await fetch("/api/finance-analysis", { method: "POST" });
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || "Bilinmeyen bir hata oluştu.");
+ }
+ setResult(data.text);
+ } catch (err: any) {
+ setResult("Hata: " + err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ AI Analizi
+
+
+
+
+
+
+ Yapay Zeka Finansal Yorumlama
+
+
+ Son 30 günlük finansal kayıtlarınızı analiz edip size önerilerde bulunuyorum.
+
+
+
+
+ {!result && !loading && (
+
+
+
+ Raporu Oluştur
+
+
+ )}
+
+ {loading && (
+
+
+
Verileriniz analiz ediliyor...
+
+ )}
+
+ {result && (
+
+ {result}
+
+ )}
+
+
+ {result && (
+
+ setOpen(false)}>Kapat
+
+
+ Yeniden Oluştur
+
+
+ )}
+
+
+ );
+}
+
function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
const totals = new Map();
for (const transaction of transactions) {
diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx
index f86ff6d..0c11192 100644
--- a/app/(dashboard)/projects/projects-client.tsx
+++ b/app/(dashboard)/projects/projects-client.tsx
@@ -32,6 +32,8 @@ import {
Plus,
Target,
Wallet,
+ Brain,
+ Loader2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -125,7 +127,10 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
-
+
@@ -751,3 +756,86 @@ function formatCurrency(value: number) {
maximumFractionDigits: 0,
}).format(value);
}
+
+function AIProjectRiskDialog({ projectId }: { projectId?: string }) {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState
(null);
+
+ const handleAnalyze = async () => {
+ setLoading(true);
+ setResult(null);
+ try {
+ const res = await fetch("/api/project-risk", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || "Bilinmeyen bir hata oluştu.");
+ }
+ setResult(data.text);
+ } catch (err: any) {
+ setResult("Hata: " + err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ AI Risk Analizi
+
+
+
+
+
+
+ Proje Risk Analizi
+
+
+ Yapay zeka, projelerinizin ilerleme durumunu ve bitiş tarihlerini kontrol ederek riskleri tahmin eder.
+
+
+
+
+ {!result && !loading && (
+
+
+
+ Raporu Oluştur
+
+
+ )}
+
+ {loading && (
+
+
+
Projeler analiz ediliyor...
+
+ )}
+
+ {result && (
+
+ {result}
+
+ )}
+
+
+ {result && (
+
+ setOpen(false)}>Kapat
+
+
+ Yeniden Oluştur
+
+
+ )}
+
+
+ );
+}
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 7dc35d0..5ea555b 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -39,22 +39,37 @@ export async function POST(req: Request) {
model = openai('gpt-4o');
}
- // Identify the latest user message to save to Supabase
+ // Identify the latest user message to save to Supabase and use for RAG
const latestMessage = messages[messages.length - 1];
- if (sessionId && latestMessage && latestMessage.role === 'user') {
- // Sadece metin varsa kaydediyoruz
- if (latestMessage.content) {
+ let ragContext = "";
+
+ if (latestMessage && latestMessage.role === 'user' && latestMessage.content) {
+ if (sessionId) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
role: "user",
content: latestMessage.content,
});
}
+
+ // Perform RAG search
+ try {
+ const { searchSimilarDocuments } = await import('@/lib/ai/embeddings');
+ const similarDocs = await searchSimilarDocuments(user.id, latestMessage.content, provider, apiKey, 3);
+ if (similarDocs && similarDocs.length > 0) {
+ ragContext = "Aşağıda kullanıcının veri tabanından sistemin otomatik bulduğu geçmiş notlar ve veriler (RAG Context) bulunmaktadır. Gerektiğinde soruları yanıtlarken bunlardan faydalan:\n\n" + similarDocs.map((doc: any) => `- ${doc.content}`).join("\n");
+ }
+ } catch (err) {
+ console.error("RAG araması başarısız:", err);
+ }
}
const systemPrompt = `Sen kullanıcının kişisel Freelancer İş Asistanı ve Danışmanısın. Cognis Freelancer OS içinde yaşıyorsun.
Kullanıcının iş süreçlerini, projelerini ve finansal durumunu organize etmesine yardımcı oluyorsun.
Gerektiğinde araçları (tools) kullanarak sistemden güncel verileri çek ve doğrudan veri ekle.
+
+${ragContext}
+
Aşağıdaki yeteneklere sahipsin:
- Finansal verileri listeleyebilir ve yeni finans kaydı (gelir/gider) girebilirsin.
- Görevleri okuyabilir ve yeni görevler ekleyebilirsin.
diff --git a/app/api/finance-analysis/route.ts b/app/api/finance-analysis/route.ts
new file mode 100644
index 0000000..881a7c5
--- /dev/null
+++ b/app/api/finance-analysis/route.ts
@@ -0,0 +1,80 @@
+import { generateText } from 'ai';
+import { createOpenAI } from '@ai-sdk/openai';
+import { createGoogleGenerativeAI } from '@ai-sdk/google';
+import { createClient } from '@/lib/supabase/server';
+
+export const maxDuration = 30;
+
+export async function POST(req: Request) {
+ try {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Yetkisiz erişim' }), { status: 401 });
+ }
+
+ const { data: appSettings } = await supabase
+ .from("app_settings")
+ .select("*")
+ .eq("user_id", user.id)
+ .single();
+
+ const provider = appSettings?.ai_provider || "openai";
+ const apiKey = appSettings?.api_key;
+
+ if (!apiKey) {
+ return new Response(JSON.stringify({ error: 'Ayarlardan AI Sağlayıcı ve API Anahtarı seçmelisiniz.' }), { status: 400 });
+ }
+
+ let model;
+ if (provider === 'gemini') {
+ const google = createGoogleGenerativeAI({ apiKey });
+ model = google('gemini-1.5-pro-latest');
+ } else if (provider === 'groq') {
+ const groq = createOpenAI({ apiKey, baseURL: 'https://api.groq.com/openai/v1' });
+ model = groq('llama-3.1-8b-instant');
+ } else {
+ const openai = createOpenAI({ apiKey });
+ model = openai('gpt-4o');
+ }
+
+ // Fetch finance data (last 30 days)
+ const pastDate = new Date();
+ pastDate.setDate(pastDate.getDate() - 30);
+ const { data: transactions } = await supabase.from('finance_transactions')
+ .select('type, amount, category, transaction_date')
+ .gte('transaction_date', pastDate.toISOString())
+ .eq('user_id', user.id);
+
+ if (!transactions || transactions.length === 0) {
+ return new Response(JSON.stringify({ text: "Son 30 güne ait herhangi bir finansal işleminiz bulunmadığı için analiz yapamıyorum. Lütfen yeni gelir/gider ekleyin." }), { status: 200 });
+ }
+
+ const totalIncome = transactions.filter(t => t.type === 'income').reduce((acc, curr) => acc + Number(curr.amount), 0);
+ const totalExpense = transactions.filter(t => t.type === 'expense').reduce((acc, curr) => acc + Number(curr.amount), 0);
+ const netProfit = totalIncome - totalExpense;
+
+ const dataSummary = `Kullanıcının son 30 günlük finansal durumu:
+- Toplam Gelir: ${totalIncome} $
+- Toplam Gider: ${totalExpense} $
+- Net Kâr: ${netProfit} $
+- İşlem Sayısı: ${transactions.length}
+İşlemler listesi:
+${transactions.map(t => `- ${t.transaction_date.slice(0, 10)} | ${t.type === 'income' ? 'Gelir' : 'Gider'} | ${t.category} | ${t.amount}$`).join('\n')}`;
+
+ const { text } = await generateText({
+ model,
+ system: `Sen profesyonel bir finans danışmanısın. Kullanıcıya verilen finansal verilere dayanarak kısa, motive edici ve yapıcı bir "Finansal Durum Raporu ve Tavsiye" sunmalısın.
+Gereksiz uzunluktan kaçın, direkt sadede gel. Sadece metin formatında, markdown başlıklar kullanarak (örn: ### Özet, ### Tavsiyeler) cevap ver. Türkçe konuş.`,
+ prompt: `Lütfen aşağıdaki verilere göre bana bir finansal özet ve kâr/gider oranım için tavsiye ver:\n\n${dataSummary}`,
+ });
+
+ return new Response(JSON.stringify({ text }), {
+ headers: { 'Content-Type': 'application/json' },
+ });
+ } catch (error: any) {
+ console.error("AI Finance Error:", error);
+ return new Response(JSON.stringify({ error: error.message }), { status: 500 });
+ }
+}
diff --git a/app/api/project-risk/route.ts b/app/api/project-risk/route.ts
new file mode 100644
index 0000000..3616a87
--- /dev/null
+++ b/app/api/project-risk/route.ts
@@ -0,0 +1,84 @@
+import { generateText } from 'ai';
+import { createOpenAI } from '@ai-sdk/openai';
+import { createGoogleGenerativeAI } from '@ai-sdk/google';
+import { createClient } from '@/lib/supabase/server';
+
+export const maxDuration = 30;
+
+export async function POST(req: Request) {
+ try {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Yetkisiz erişim' }), { status: 401 });
+ }
+
+ const { projectId } = await req.json();
+
+ const { data: appSettings } = await supabase
+ .from("app_settings")
+ .select("*")
+ .eq("user_id", user.id)
+ .single();
+
+ const provider = appSettings?.ai_provider || "openai";
+ const apiKey = appSettings?.api_key;
+
+ if (!apiKey) {
+ return new Response(JSON.stringify({ error: 'Ayarlardan AI Sağlayıcı ve API Anahtarı seçmelisiniz.' }), { status: 400 });
+ }
+
+ let model;
+ if (provider === 'gemini') {
+ const google = createGoogleGenerativeAI({ apiKey });
+ model = google('gemini-1.5-pro-latest');
+ } else if (provider === 'groq') {
+ const groq = createOpenAI({ apiKey, baseURL: 'https://api.groq.com/openai/v1' });
+ model = groq('llama-3.1-8b-instant');
+ } else {
+ const openai = createOpenAI({ apiKey });
+ model = openai('gpt-4o');
+ }
+
+ // Fetch project details
+ let projectDataStr = "";
+ if (projectId) {
+ const { data: project } = await supabase.from('projects').select('*, clients(name)').eq('id', projectId).single();
+ if (!project) return new Response(JSON.stringify({ error: 'Proje bulunamadı.' }), { status: 404 });
+
+ const { data: tasks } = await supabase.from('tasks').select('status').eq('project_id', projectId);
+
+ const completedTasks = tasks?.filter(t => t.status === 'completed').length || 0;
+ const totalTasks = tasks?.length || 0;
+
+ projectDataStr = `Proje Adı: ${project.name}
+Müşteri: ${project.clients?.name || 'Bilinmiyor'}
+Durum: ${project.status}
+Bütçe: ${project.budget_amount || 0} ${project.currency}
+İlerleme: %${project.progress}
+Başlangıç: ${project.start_date || 'Bilinmiyor'}
+Bitiş (Deadline): ${project.due_date || 'Bilinmiyor'}
+Görevler: ${totalTasks} adet (${completedTasks} tamamlandı)`;
+ } else {
+ // Analyze all active projects
+ const { data: projects } = await supabase.from('projects').select('name, status, due_date, progress').eq('user_id', user.id).eq('status', 'active');
+ if (!projects || projects.length === 0) return new Response(JSON.stringify({ error: 'Aktif proje bulunamadı.' }), { status: 404 });
+
+ projectDataStr = `Aktif Projeler:\n${projects.map(p => `- ${p.name} | İlerleme: %${p.progress} | Deadline: ${p.due_date || 'Yok'}`).join('\n')}`;
+ }
+
+ const { text } = await generateText({
+ model,
+ system: `Sen bir Proje Yönetim Uzmanısın. Verilen proje bilgilerini analiz ederek kısa, net ve aksiyon odaklı bir "Risk ve Durum Raporu" oluşturmalısın. Türkçe yanıt ver.`,
+ prompt: `Lütfen aşağıdaki proje verilerine göre riskleri ve önerilerini belirt:\n\n${projectDataStr}`,
+ });
+
+ return new Response(JSON.stringify({ text }), {
+ headers: { 'Content-Type': 'application/json' },
+ });
+ } catch (error: any) {
+ console.error("AI Project Risk Error:", error);
+ return new Response(JSON.stringify({ error: error.message }), { status: 500 });
+ }
+}
diff --git a/lib/ai/embeddings.ts b/lib/ai/embeddings.ts
new file mode 100644
index 0000000..960645b
--- /dev/null
+++ b/lib/ai/embeddings.ts
@@ -0,0 +1,72 @@
+import { embed } from 'ai';
+import { createOpenAI } from '@ai-sdk/openai';
+import { createGoogleGenerativeAI } from '@ai-sdk/google';
+import { createClient } from '@/lib/supabase/server';
+
+export async function generateEmbedding(text: string, provider: string, apiKey: string) {
+ let embeddingModel;
+
+ if (provider === 'google' && apiKey) {
+ const google = createGoogleGenerativeAI({ apiKey });
+ embeddingModel = google.textEmbeddingModel('text-embedding-004');
+ } else if (apiKey) {
+ const openai = createOpenAI({ apiKey });
+ embeddingModel = openai.embedding('text-embedding-3-small');
+ } else {
+ throw new Error('Geçerli bir API Anahtarı bulunamadı.');
+ }
+
+ const { embedding } = await embed({
+ model: embeddingModel,
+ value: text,
+ });
+
+ return embedding;
+}
+
+export async function saveDocumentEmbedding(
+ userId: string,
+ content: string,
+ metadata: Record,
+ provider: string,
+ apiKey: string
+) {
+ const embedding = await generateEmbedding(content, provider, apiKey);
+ const supabase = await createClient();
+
+ const { error } = await supabase.from('document_embeddings').insert({
+ user_id: userId,
+ content,
+ metadata,
+ embedding,
+ });
+
+ if (error) {
+ console.error('Embedding kayıt hatası:', error);
+ throw new Error('Embedding kaydedilemedi.');
+ }
+}
+
+export async function searchSimilarDocuments(
+ userId: string,
+ query: string,
+ provider: string,
+ apiKey: string,
+ matchCount: number = 5
+) {
+ const queryEmbedding = await generateEmbedding(query, provider, apiKey);
+ const supabase = await createClient();
+
+ const { data, error } = await supabase.rpc('match_documents', {
+ query_embedding: queryEmbedding,
+ match_count: matchCount,
+ filter_user_id: userId,
+ });
+
+ if (error) {
+ console.error('Vektör arama hatası:', error);
+ return [];
+ }
+
+ return data;
+}
diff --git a/package.json b/package.json
index 58e151d..c614edd 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.68",
+ "@ai-sdk/react": "^3.0.199",
"@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
"@iconify/react": "^6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 00e7798..335647d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@ai-sdk/openai':
specifier: ^3.0.68
version: 3.0.68(zod@4.4.3)
+ '@ai-sdk/react':
+ specifier: ^3.0.199
+ version: 3.0.199(react@19.2.7)(zod@4.4.3)
'@base-ui/react':
specifier: ^1.5.0
version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -181,6 +184,12 @@ packages:
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
+ '@ai-sdk/react@3.0.199':
+ resolution: {integrity: sha512-0QmG6nd1iDTTWpWbQbE5qgSpEm0XkBvrOn1L1rSzBhG5+7BasckcjTF3CQMwUxdvozMMYRNOGXLQODs/1+a3NQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -2364,6 +2373,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -4029,6 +4042,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ swr@2.4.1:
+ resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -4048,6 +4066,10 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ throttleit@2.1.0:
+ resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
+ engines: {node: '>=18'}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4349,6 +4371,16 @@ snapshots:
dependencies:
json-schema: 0.4.0
+ '@ai-sdk/react@3.0.199(react@19.2.7)(zod@4.4.3)':
+ dependencies:
+ '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
+ ai: 6.0.197(zod@4.4.3)
+ react: 19.2.7
+ swr: 2.4.1(react@19.2.7)
+ throttleit: 2.1.0
+ transitivePeerDependencies:
+ - zod
+
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.7':
@@ -6501,6 +6533,8 @@ snapshots:
depd@2.0.0: {}
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -8458,6 +8492,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ swr@2.4.1(react@19.2.7):
+ dependencies:
+ dequal: 2.0.3
+ react: 19.2.7
+ use-sync-external-store: 1.6.0(react@19.2.7)
+
tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {}
@@ -8470,6 +8510,8 @@ snapshots:
tapable@2.3.3: {}
+ throttleit@2.1.0: {}
+
tiny-invariant@1.3.3: {}
tinyglobby@0.2.17:
diff --git a/supabase/migrations/0005_add_advanced_crm_tables.sql b/supabase/migrations/0005_add_advanced_crm_tables.sql
new file mode 100644
index 0000000..4ad08ae
--- /dev/null
+++ b/supabase/migrations/0005_add_advanced_crm_tables.sql
@@ -0,0 +1,30 @@
+-- 0005: Faz 7 - Advanced CRM Tables
+
+-- 1. Alter clients table to add CRM specific columns
+alter table public.clients
+add column if not exists pipeline_stage text default 'lead'::text check (pipeline_stage in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')),
+add column if not exists next_follow_up_date timestamp with time zone,
+add column if not exists last_contact_date timestamp with time zone,
+add column if not exists client_value_score numeric(5,2) default 0;
+
+-- 2. Create client_activities table
+create table if not exists public.client_activities (
+ id uuid default uuid_generate_v4() primary key,
+ user_id uuid references auth.users(id) on delete cascade not null,
+ client_id uuid references public.clients(id) on delete cascade not null,
+ type text not null check (type in ('note', 'call', 'meeting', 'email')),
+ title text not null,
+ content text,
+ activity_date timestamp with time zone default timezone('utc'::text, now()) not null,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+-- Enable RLS
+alter table public.client_activities enable row level security;
+
+-- Client Activities RLS
+create policy "Users can view their own client activities" on public.client_activities for select using (auth.uid() = user_id);
+create policy "Users can insert their own client activities" on public.client_activities for insert with check (auth.uid() = user_id);
+create policy "Users can update their own client activities" on public.client_activities for update using (auth.uid() = user_id);
+create policy "Users can delete their own client activities" on public.client_activities for delete using (auth.uid() = user_id);
diff --git a/supabase/migrations/0006_add_pgvector_and_embeddings.sql b/supabase/migrations/0006_add_pgvector_and_embeddings.sql
new file mode 100644
index 0000000..0f017c9
--- /dev/null
+++ b/supabase/migrations/0006_add_pgvector_and_embeddings.sql
@@ -0,0 +1,50 @@
+-- 0006: Faz 8 - pgvector & RAG Embeddings
+
+-- Enable the pgvector extension to work with embedding vectors
+create extension if not exists vector;
+
+-- Create a table to store document embeddings for RAG
+create table if not exists public.document_embeddings (
+ id uuid default uuid_generate_v4() primary key,
+ user_id uuid references auth.users(id) on delete cascade not null,
+ content text not null,
+ metadata jsonb, -- e.g. { "source_type": "note", "source_id": "123" }
+ embedding vector(1536), -- 1536 works for OpenAI text-embedding-3-small and text-embedding-ada-002
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+-- Enable RLS
+alter table public.document_embeddings enable row level security;
+
+create policy "Users can view their own embeddings" on public.document_embeddings for select using (auth.uid() = user_id);
+create policy "Users can insert their own embeddings" on public.document_embeddings for insert with check (auth.uid() = user_id);
+create policy "Users can update their own embeddings" on public.document_embeddings for update using (auth.uid() = user_id);
+create policy "Users can delete their own embeddings" on public.document_embeddings for delete using (auth.uid() = user_id);
+
+-- Create a function to similarity search for embeddings
+create or replace function match_documents (
+ query_embedding vector(1536),
+ match_count int default null,
+ filter_user_id uuid default null
+) returns table (
+ id uuid,
+ content text,
+ metadata jsonb,
+ similarity float
+)
+language plpgsql
+as $$
+#variable_conflict use_column
+begin
+ return query
+ select
+ document_embeddings.id,
+ document_embeddings.content,
+ document_embeddings.metadata,
+ 1 - (document_embeddings.embedding <=> query_embedding) as similarity
+ from document_embeddings
+ where document_embeddings.user_id = filter_user_id
+ order by document_embeddings.embedding <=> query_embedding
+ limit match_count;
+end;
+$$;