feat: add AI-powered finance and project risk analysis features with pgvector support for RAG embeddings

This commit is contained in:
poyrazavsever
2026-06-06 21:47:50 +03:00
parent 21af4b7a62
commit 83379a5edc
17 changed files with 1104 additions and 241 deletions
+5 -5
View File
@@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from "react";
import { Brain, Send, MessageSquare, Plus, Trash2, Loader2, Wrench } from "lucide-react"; import { Brain, Send, MessageSquare, Plus, Trash2, Loader2, Wrench } from "lucide-react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Button } from "poyraz-ui/atoms"; import { Button } from "poyraz-ui/atoms";
import { useChat } from "ai/react"; import { useChat } from "@ai-sdk/react";
interface ChatSession { interface ChatSession {
id: string; id: string;
@@ -107,10 +107,10 @@ export default function AIChatPage() {
const customHandleSubmit = async (e: React.FormEvent) => { const customHandleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!input.trim() || isLoading) return; if (!(input || "").trim() || isLoading) return;
let sessionId = activeSessionId; let sessionId = activeSessionId;
const currentInput = input; const currentInput = input || "";
setInput(""); setInput("");
if (!sessionId) { if (!sessionId) {
@@ -309,8 +309,8 @@ export default function AIChatPage() {
<Button <Button
type="submit" type="submit"
size="icon" size="icon"
className={`h-10 w-10 rounded-lg ${input.trim() ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-muted-foreground'}`} className={`h-10 w-10 rounded-lg ${(input || "").trim() ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-muted-foreground'}`}
disabled={!input.trim()} disabled={!(input || "").trim()}
> >
<Send className="h-4 w-4 ml-0.5" /> <Send className="h-4 w-4 ml-0.5" />
</Button> </Button>
+49
View File
@@ -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`);
}
@@ -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 <Phone className="h-4 w-4 text-blue-500" />;
case "meeting": return <Calendar className="h-4 w-4 text-emerald-500" />;
case "email": return <Mail className="h-4 w-4 text-amber-500" />;
default: return <MessageSquare className="h-4 w-4 text-muted-foreground" />;
}
};
const getActivityBadge = (type: string) => {
switch (type) {
case "call": return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">Arama</Badge>;
case "meeting": return <Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">Toplantı</Badge>;
case "email": return <Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20">E-posta</Badge>;
default: return <Badge variant="secondary">Not</Badge>;
}
};
async function handleAddActivity(formData: FormData) {
setIsAddingActivity(true);
try {
await addClientActivity(client.id, formData);
setOpenDialog(false);
} finally {
setIsAddingActivity(false);
}
}
return (
<div className="mx-auto flex max-w-5xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header Info */}
<div className="flex flex-col gap-4 md:flex-row md:items-start justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-2xl font-semibold text-primary">
{client.name.split(" ").slice(0, 2).map(n => n[0]?.toUpperCase()).join("")}
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">{client.name}</h1>
{client.company_name && <p className="text-muted-foreground mt-1">{client.company_name}</p>}
</div>
</div>
<div className="flex gap-2">
<Badge variant="outline" className="px-3 py-1 capitalize text-sm">{client.status}</Badge>
<Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20 px-3 py-1 capitalize text-sm">
{client.pipeline_stage.replace('_', ' ')}
</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column: Contact & Details */}
<div className="space-y-6">
<Card>
<CardContent className="p-5 space-y-4">
<h3 className="font-semibold text-foreground">İletişim Bilgileri</h3>
<div className="space-y-3 text-sm">
{client.email ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${client.email}`} className="hover:text-primary transition-colors">{client.email}</a>
</div>
) : null}
{client.phone ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<a href={`tel:${client.phone}`} className="hover:text-primary transition-colors">{client.phone}</a>
</div>
) : null}
{client.website ? (
<div className="flex items-center gap-3 text-muted-foreground">
<ExternalLink className="h-4 w-4 shrink-0" />
<a href={client.website} target="_blank" rel="noreferrer" className="hover:text-primary transition-colors">
{client.website.replace(/^https?:\/\//, "")}
</a>
</div>
) : null}
{!client.email && !client.phone && !client.website && (
<p className="text-muted-foreground italic">İletişim bilgisi girilmemiş.</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-5 space-y-4">
<h3 className="font-semibold text-foreground">Genel Notlar</h3>
{client.notes ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{client.notes}</p>
) : (
<p className="text-sm text-muted-foreground italic">Müşteriye ait genel not bulunmuyor.</p>
)}
</CardContent>
</Card>
</div>
{/* Right Column: Activities & Timeline */}
<div className="md:col-span-2 space-y-6">
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-6">
<h3 className="font-semibold text-foreground">Aktivite Geçmişi</h3>
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" /> Aktivite Ekle
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAddActivity} className="space-y-4">
<DialogHeader>
<DialogTitle>Yeni Aktivite Ekle</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Tip</Label>
<Select name="type" defaultValue="note">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="note">Not</SelectItem>
<SelectItem value="call">Arama</SelectItem>
<SelectItem value="meeting">Toplantı</SelectItem>
<SelectItem value="email">E-posta</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Başlık</Label>
<Input name="title" required placeholder="Aktivite özeti" />
</div>
<div className="grid gap-2">
<Label>Tarih</Label>
<Input name="activity_date" type="datetime-local" required defaultValue={new Date().toISOString().slice(0, 16)} />
</div>
<div className="grid gap-2">
<Label>İçerik (Opsiyonel)</Label>
<Textarea name="content" rows={4} placeholder="Görüşme detayları..." />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isAddingActivity}>
{isAddingActivity ? "Ekleniyor..." : "Ekle"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-border before:to-transparent">
{activities.length === 0 ? (
<div className="text-center py-10">
<p className="text-muted-foreground">Henüz kaydedilmiş bir aktivite yok.</p>
</div>
) : (
activities.map((activity) => (
<div key={activity.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
{/* Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border-4 border-background bg-muted/50 text-slate-500 shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 z-10">
{getActivityIcon(activity.type)}
</div>
{/* Card */}
<Card className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex justify-between items-start mb-2">
<h4 className="font-semibold text-foreground">{activity.title}</h4>
{getActivityBadge(activity.type)}
</div>
<time className="text-xs text-muted-foreground block mb-2 font-medium">
{format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: tr })}
</time>
{activity.content && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.content}</p>
)}
</CardContent>
</Card>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+34
View File
@@ -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 <ClientDetailClient client={client} activities={activities} />;
}
+4
View File
@@ -58,6 +58,8 @@ export async function createClientRecord(formData: FormData) {
website: cleanWebsite(formData.get("website")), website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")), status: readStatus(formData.get("status")),
notes: cleanText(formData.get("notes")), 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) { if (error) {
@@ -86,6 +88,8 @@ export async function updateClientRecord(formData: FormData) {
website: cleanWebsite(formData.get("website")), website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")), status: readStatus(formData.get("status")),
notes: cleanText(formData.get("notes")), 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("id", id)
.eq("user_id", userId); .eq("user_id", userId);
+184 -179
View File
@@ -19,6 +19,10 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "poyraz-ui/molecules"; } from "poyraz-ui/molecules";
import { import {
Archive, Archive,
@@ -31,10 +35,14 @@ import {
UserCheck, UserCheck,
Users, Users,
Wallet, Wallet,
Clock,
ArrowRight,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { format, isPast, isToday } from "date-fns";
import { tr } from "date-fns/locale";
export type ClientListItem = { export type ClientListItem = {
id: string; id: string;
@@ -48,6 +56,11 @@ export type ClientListItem = {
created_at: string; created_at: string;
projectCount: number; projectCount: number;
revenueTotal: 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 = { const statusLabels = {
@@ -62,6 +75,14 @@ const statusClasses = {
archived: "border-zinc-200 bg-zinc-50 text-zinc-600", 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 = { type ClientsClientProps = {
clients: ClientListItem[]; clients: ClientListItem[];
totalRevenue: number; totalRevenue: number;
@@ -79,6 +100,7 @@ export function ClientsClient({
}: ClientsClientProps) { }: ClientsClientProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filteredClients = normalizedQuery const filteredClients = normalizedQuery
? clients.filter((client) => ? clients.filter((client) =>
[ [
@@ -95,20 +117,19 @@ export function ClientsClient({
: clients; : clients;
return ( return (
<div className="mx-auto flex max-w-7xl flex-col gap-6"> <div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Freelancer operasyonu CRM & Operasyon
</div> </div>
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Müşteriler CRM & Müşteriler
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Çalıştığın müşterileri, iletişim bilgilerini ve temel durumunu tek Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet.
ekrandan yönet.
</p> </p>
</div> </div>
</div> </div>
@@ -118,25 +139,25 @@ export function ClientsClient({
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard <StatCard
label="Aktif müşteri" label="Potansiyel (Lead)"
value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
icon={Users}
iconClassName="bg-blue-50 text-blue-700"
/>
<StatCard
label="Aktif Müşteri"
value={activeCount.toString()} value={activeCount.toString()}
icon={UserCheck} icon={UserCheck}
iconClassName="bg-emerald-50 text-emerald-700" iconClassName="bg-emerald-50 text-emerald-700"
/> />
<StatCard <StatCard
label="Duraklatıldı" label="Bekleyen Follow-up"
value={pausedCount.toString()} value={clients.filter(c => c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()}
icon={PauseCircle} icon={Clock}
iconClassName="bg-amber-50 text-amber-700" iconClassName="bg-rose-50 text-rose-700"
/> />
<StatCard <StatCard
label="Arşiv" label="Kayıtlı Gelir"
value={archivedCount.toString()}
icon={Archive}
iconClassName="bg-zinc-100 text-zinc-700"
/>
<StatCard
label="Kayıtlı gelir"
value={formatCurrency(totalRevenue)} value={formatCurrency(totalRevenue)}
description="Ödenmiş gelir işlemleri" description="Ödenmiş gelir işlemleri"
icon={Wallet} icon={Wallet}
@@ -144,17 +165,13 @@ export function ClientsClient({
/> />
</div> </div>
<Card> <Tabs defaultValue="pipeline" className="w-full">
<CardContent className="space-y-4 p-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <TabsList>
<div> <TabsTrigger value="pipeline">Pipeline (Kanban)</TabsTrigger>
<h2 className="text-base font-semibold text-foreground"> <TabsTrigger value="list">Müşteri Listesi</TabsTrigger>
Müşteri listesi </TabsList>
</h2>
<p className="text-sm text-muted-foreground">
{filteredClients.length} kayıt görüntüleniyor.
</p>
</div>
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -163,12 +180,64 @@ export function ClientsClient({
/> />
</div> </div>
<TabsContent value="pipeline" className="mt-0">
<div className="flex gap-4 overflow-x-auto pb-4 snap-x">
{pipelineStages.map(stage => {
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
return (
<div key={stage.id} className="flex-shrink-0 w-80 bg-muted/30 rounded-lg border border-border p-3 snap-start flex flex-col h-[calc(100vh-320px)] min-h-[500px]">
<div className="flex items-center justify-between mb-3 px-1">
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${stage.color.split(' ')[1]}`}></span>
{stage.label}
</h3>
<Badge variant="secondary" className="text-xs">{stageClients.length}</Badge>
</div>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 tiny-scrollbar">
{stageClients.map(client => (
<Card key={client.id} className="cursor-pointer hover:border-primary/50 transition-colors">
<CardContent className="p-3">
<div className="flex justify-between items-start mb-2">
<Link href={`/clients/${client.id}`} className="font-medium text-foreground hover:underline">
{client.name}
</Link>
<ClientDialog mode="edit" client={client} trigger={<Button variant="ghost" className="h-6 w-6 p-0"><Pencil className="h-3 w-3" /></Button>} />
</div>
{client.company_name && <p className="text-xs text-muted-foreground mb-2">{client.company_name}</p>}
{client.next_follow_up_date && (
<div className="mt-3 flex items-center gap-1.5 text-xs">
<Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} />
<span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}>
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
</span>
</div>
)}
</CardContent>
</Card>
))}
{stageClients.length === 0 && (
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
Boş
</div>
)}
</div>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="list" className="mt-0">
<Card>
<CardContent className="p-0">
{filteredClients.length > 0 ? ( {filteredClients.length > 0 ? (
<div className="overflow-hidden rounded-sm border border-border"> <div className="overflow-hidden rounded-sm border border-border">
<div className="hidden grid-cols-[1.5fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid"> <div className="hidden grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
<span>Müşteri</span> <span>Müşteri</span>
<span>İletişim</span> <span>İletişim</span>
<span>Durum</span> <span>Aşama</span>
<span>Follow-up</span>
<span>Projeler</span> <span>Projeler</span>
<span className="text-right">İşlem</span> <span className="text-right">İşlem</span>
</div> </div>
@@ -183,20 +252,25 @@ export function ClientsClient({
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
</Tabs>
</div> </div>
); );
} }
function ClientRow({ client }: { client: ClientListItem }) { 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 ( return (
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[1.5fr_1fr_1fr_0.8fr_0.8fr] lg:items-center"> <div className="grid gap-4 px-4 py-4 lg:grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] lg:items-center">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-sm font-semibold text-primary"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-sm font-semibold text-primary">
{getInitials(client.name)} {getInitials(client.name)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium text-foreground">{client.name}</div> <Link href={`/clients/${client.id}`} className="truncate font-medium text-foreground hover:underline block">{client.name}</Link>
<div className="truncate text-sm text-muted-foreground"> <div className="truncate text-sm text-muted-foreground">
{client.company_name || "Firma bilgisi yok"} {client.company_name || "Firma bilgisi yok"}
</div> </div>
@@ -217,43 +291,40 @@ function ClientRow({ client }: { client: ClientListItem }) {
<span className="truncate">{client.phone}</span> <span className="truncate">{client.phone}</span>
</Link> </Link>
) : null} ) : null}
{client.website ? (
<Link
href={getWebsiteHref(client.website)}
target="_blank"
className="flex items-center gap-2 hover:text-primary"
>
<ExternalLink className="h-3.5 w-3.5" />
<span className="truncate">{client.website.replace(/^https?:\/\//, "")}</span>
</Link>
) : null}
{!client.email && !client.phone && !client.website ? ( {!client.email && !client.phone && !client.website ? (
<span>İletişim bilgisi yok</span> <span>İletişim bilgisi yok</span>
) : null} ) : null}
</div> </div>
<div> <div>
<Badge className={statusClasses[client.status]}> <Badge className={stage.color}>
{statusLabels[client.status]} {stage.label}
</Badge> </Badge>
</div> </div>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-foreground">{client.projectCount}</div> {client.next_follow_up_date ? (
<div className={`flex items-center gap-1.5 ${isFollowUpOverdue ? 'text-rose-600 font-medium' : 'text-muted-foreground'}`}>
<Clock className="h-3.5 w-3.5" />
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
</div>
) : (
<span className="text-muted-foreground opacity-50">-</span>
)}
</div>
<div className="text-sm">
<div className="font-medium text-foreground">{client.projectCount} Proje</div>
<div className="text-muted-foreground">{formatCurrency(client.revenueTotal)}</div> <div className="text-muted-foreground">{formatCurrency(client.revenueTotal)}</div>
</div> </div>
<div className="flex justify-start gap-2 lg:justify-end"> <div className="flex justify-start gap-2 lg:justify-end">
<ClientDialog mode="edit" client={client} /> <Link href={`/clients/${client.id}`}>
{client.status !== "archived" ? ( <Button variant="ghost" className="h-9 w-9 p-0">
<form action={archiveClientRecord}> <ArrowRight className="h-4 w-4" />
<input type="hidden" name="id" value={client.id} />
<Button type="submit" variant="outline" className="h-9 min-w-24 gap-2 px-3">
<Archive className="h-4 w-4" />
Arşivle
</Button> </Button>
</form> </Link>
) : null} <ClientDialog mode="edit" client={client} trigger={<Button variant="outline" className="h-9 min-w-20 gap-2 px-3"><Pencil className="h-4 w-4" /> Düzenle</Button>} />
</div> </div>
</div> </div>
); );
@@ -262,9 +333,11 @@ function ClientRow({ client }: { client: ClientListItem }) {
function ClientDialog({ function ClientDialog({
mode, mode,
client, client,
trigger
}: { }: {
mode: "create" | "edit"; mode: "create" | "edit";
client?: ClientListItem; client?: ClientListItem;
trigger?: React.ReactNode;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -284,6 +357,7 @@ function ClientDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || (
<Button <Button
variant={mode === "create" ? "default" : "outline"} variant={mode === "create" ? "default" : "outline"}
className="h-9 min-w-24 gap-2 px-3" className="h-9 min-w-24 gap-2 px-3"
@@ -291,8 +365,9 @@ function ClientDialog({
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />} {mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Müşteri ekle" : "Düzenle"} {mode === "create" ? "Müşteri ekle" : "Düzenle"}
</Button> </Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"> <DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<form action={handleSubmit} className="space-y-5"> <form action={handleSubmit} className="space-y-5">
{client ? <input type="hidden" name="id" value={client.id} /> : null} {client ? <input type="hidden" name="id" value={client.id} /> : null}
<DialogHeader> <DialogHeader>
@@ -300,8 +375,7 @@ function ClientDialog({
{mode === "create" ? "Yeni müşteri" : "Müşteriyi düzenle"} {mode === "create" ? "Yeni müşteri" : "Müşteriyi düzenle"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Müşteri bilgilerini sade tut; proje ve finans bağlantıları sonraki Müşterinin iletişim ve CRM detaylarını girin.
modüllerden otomatik görünecek.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -326,6 +400,7 @@ function ClientDialog({
function ClientFormFields({ client }: { client?: ClientListItem }) { function ClientFormFields({ client }: { client?: ClientListItem }) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`name-${client?.id || "new"}`}>Müşteri adı</Label> <Label htmlFor={`name-${client?.id || "new"}`}>Müşteri adı</Label>
<Input <Input
@@ -336,7 +411,6 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
placeholder="Örn. Acme Corp" placeholder="Örn. Acme Corp"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`company-${client?.id || "new"}`}>Firma / marka adı</Label> <Label htmlFor={`company-${client?.id || "new"}`}>Firma / marka adı</Label>
<Input <Input
@@ -346,8 +420,50 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
placeholder="Opsiyonel" placeholder="Opsiyonel"
/> />
</div> </div>
</div>
<div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2">
<div className="grid gap-2">
<Label>Satış Aşaması (Pipeline)</Label>
<Select name="pipeline_stage" defaultValue={client?.pipeline_stage || "lead"}>
<SelectTrigger>
<SelectValue placeholder="Aşama seç" />
</SelectTrigger>
<SelectContent>
{pipelineStages.map(stage => (
<SelectItem key={stage.id} value={stage.id}>{stage.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Durum</Label>
<Select name="status" defaultValue={client?.status || "active"}>
<SelectTrigger>
<SelectValue placeholder="Durum seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="paused">Duraklatıldı</SelectItem>
<SelectItem value="archived">Arşivlendi</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`followup-${client?.id || "new"}`}>Sonraki Follow-up Tarihi</Label>
<Input
id={`followup-${client?.id || "new"}`}
name="next_follow_up_date"
type="date"
defaultValue={client?.next_follow_up_date ? new Date(client.next_follow_up_date).toISOString().slice(0, 10) : ""}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`email-${client?.id || "new"}`}>E-posta</Label> <Label htmlFor={`email-${client?.id || "new"}`}>E-posta</Label>
<Input <Input
@@ -368,55 +484,22 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`website-${client?.id || "new"}`}>Web sitesi</Label> <Label htmlFor={`notes-${client?.id || "new"}`}>Genel Notlar</Label>
<WebsiteInput
id={`website-${client?.id || "new"}`}
name="website"
defaultValue={client?.website || ""}
/>
</div>
<div className="grid gap-2">
<Label>Durum</Label>
<Select name="status" defaultValue={client?.status || "active"}>
<SelectTrigger>
<SelectValue placeholder="Durum seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="paused">Duraklatıldı</SelectItem>
<SelectItem value="archived">Arşivlendi</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`notes-${client?.id || "new"}`}>Notlar</Label>
<Textarea <Textarea
id={`notes-${client?.id || "new"}`} id={`notes-${client?.id || "new"}`}
name="notes" name="notes"
defaultValue={client?.notes || ""} defaultValue={client?.notes || ""}
placeholder="İletişim notları, beklentiler, özel bilgiler..." placeholder="İletişim notları, beklentiler, özel bilgiler..."
rows={4} rows={3}
/> />
</div> </div>
</div> </div>
); );
} }
function PhoneInput({ function PhoneInput({ id, name, defaultValue }: { id: string; name: string; defaultValue: string; }) {
id,
name,
defaultValue,
}: {
id: string;
name: string;
defaultValue: string;
}) {
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
return ( return (
<Input <Input
id={id} id={id}
@@ -429,43 +512,7 @@ function PhoneInput({
); );
} }
function WebsiteInput({ function StatCard({ label, value, description, icon: Icon, iconClassName }: { label: string; value: string; description?: string; icon: LucideIcon; iconClassName: string; }) {
id,
name,
defaultValue,
}: {
id: string;
name: string;
defaultValue: string;
}) {
const [value, setValue] = useState(defaultValue);
return (
<Input
id={id}
name={name}
value={value}
inputMode="url"
placeholder="https://poyrazavsever.com"
onChange={(event) => 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;
}) {
return ( return (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
@@ -473,9 +520,7 @@ function StatCard({
<div> <div>
<p className="text-sm text-muted-foreground">{label}</p> <p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p> <p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
{description ? ( {description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
) : null}
</div> </div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${iconClassName}`}> <div className={`flex h-10 w-10 items-center justify-center rounded-sm ${iconClassName}`}>
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
@@ -494,73 +539,33 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
{hasQuery ? "Aramana uygun müşteri yok" : "Henüz müşteri eklenmedi"} {hasQuery ? "Aramana uygun müşteri yok" : "Henüz müşteri eklenmedi"}
</h3> </h3>
<p className="mt-2 max-w-md text-sm text-muted-foreground"> <p className="mt-2 max-w-md text-sm text-muted-foreground">
{hasQuery İlk müşterini ekleyerek potansiyel satışlarını takip etmeye başla.
? "Arama metnini sadeleştirerek tekrar deneyebilirsin."
: "İlk müşterini ekleyerek proje, görev ve finans kayıtlarını müşteriyle ilişkilendirmeye başlayabilirsin."}
</p> </p>
</div> </div>
); );
} }
function getInitials(name: string) { function getInitials(name: string) {
return name return name.split(" ").filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join("");
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
} }
function formatPhone(input: string) { function formatPhone(input: string) {
const digits = input.replace(/\D/g, ""); const digits = input.replace(/\D/g, "");
if (!digits) return "";
if (!digits) { const local = digits.startsWith("90") ? digits.slice(2, 12) : digits.startsWith("0") ? digits.slice(1, 11) : digits.slice(0, 10);
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 area = local.slice(0, 3);
const first = local.slice(3, 6); const first = local.slice(3, 6);
const second = local.slice(6, 8); const second = local.slice(6, 8);
const third = local.slice(8, 10); const third = local.slice(8, 10);
let formatted = "+90"; let formatted = "+90";
if (area) formatted += ` (${area}`; if (area) formatted += ` (${area}`;
if (area.length === 3) formatted += ")"; if (area.length === 3) formatted += ")";
if (first) formatted += ` ${first}`; if (first) formatted += ` ${first}`;
if (second) formatted += ` ${second}`; if (second) formatted += ` ${second}`;
if (third) formatted += ` ${third}`; if (third) formatted += ` ${third}`;
return formatted; 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) { function formatCurrency(value: number) {
return new Intl.NumberFormat("tr-TR", { return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
} }
+5 -1
View File
@@ -10,6 +10,10 @@ type ClientRow = {
website: string | null; website: string | null;
status: "active" | "paused" | "archived"; status: "active" | "paused" | "archived";
notes: string | null; 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; created_at: string;
}; };
@@ -38,7 +42,7 @@ export default async function ClientsPage() {
await Promise.all([ await Promise.all([
supabase supabase
.from("clients") .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) .eq("user_id", user.id)
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
supabase.from("projects").select("client_id").eq("user_id", user.id), supabase.from("projects").select("client_id").eq("user_id", user.id),
+84 -2
View File
@@ -27,6 +27,8 @@ import {
Plus, Plus,
Trash2, Trash2,
Wallet, Wallet,
Brain,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -125,10 +127,11 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et. Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et.
</p> </p>
</div> </div>
</div> <div className="flex gap-2">
<AIFinanceDialog />
<FinanceDialog mode="create" clients={clients} projects={projects} /> <FinanceDialog mode="create" clients={clients} projects={projects} />
</div> </div>
</div>
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6"> <div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
<StatCard label="Aylık gelir" value={formatCurrency(summary.income)} tone="green" /> <StatCard label="Aylık gelir" value={formatCurrency(summary.income)} tone="green" />
@@ -538,6 +541,85 @@ function calculateSummary(transactions: FinanceTransactionItem[]) {
); );
} }
function AIFinanceDialog() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2 bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100 hover:text-indigo-800">
<Brain className="h-4 w-4" />
AI Analizi
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Brain className="h-5 w-5 text-indigo-600" />
Yapay Zeka Finansal Yorumlama
</DialogTitle>
<DialogDescription>
Son 30 günlük finansal kayıtlarınızı analiz edip size önerilerde bulunuyorum.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{!result && !loading && (
<div className="text-center py-10">
<Button onClick={handleAnalyze} className="gap-2 bg-indigo-600 hover:bg-indigo-700 text-white">
<Brain className="h-4 w-4" />
Raporu Oluştur
</Button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-indigo-600">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm font-medium">Verileriniz analiz ediliyor...</p>
</div>
)}
{result && (
<div className="bg-muted/50 border border-border rounded-lg p-5 text-sm prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{result}
</div>
)}
</div>
{result && (
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Kapat</Button>
<Button variant="default" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
Yeniden Oluştur
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
function calculateExpenseCategories(transactions: FinanceTransactionItem[]) { function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
const totals = new Map<string, number>(); const totals = new Map<string, number>();
for (const transaction of transactions) { for (const transaction of transactions) {
@@ -32,6 +32,8 @@ import {
Plus, Plus,
Target, Target,
Wallet, Wallet,
Brain,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -125,8 +127,11 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
</div> </div>
</div> </div>
<div className="flex gap-2">
<AIProjectRiskDialog />
<ProjectDialog mode="create" clients={clients} /> <ProjectDialog mode="create" clients={clients} />
</div> </div>
</div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard label="Aktif proje" value={activeCount.toString()} icon={FolderKanban} tone="green" /> <StatCard label="Aktif proje" value={activeCount.toString()} icon={FolderKanban} tone="green" />
@@ -751,3 +756,86 @@ function formatCurrency(value: number) {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).format(value);
} }
function AIProjectRiskDialog({ projectId }: { projectId?: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2 bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100 hover:text-indigo-800">
<Brain className="h-4 w-4" />
AI Risk Analizi
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Brain className="h-5 w-5 text-indigo-600" />
Proje Risk Analizi
</DialogTitle>
<DialogDescription>
Yapay zeka, projelerinizin ilerleme durumunu ve bitiş tarihlerini kontrol ederek riskleri tahmin eder.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{!result && !loading && (
<div className="text-center py-10">
<Button onClick={handleAnalyze} className="gap-2 bg-indigo-600 hover:bg-indigo-700 text-white">
<Brain className="h-4 w-4" />
Raporu Oluştur
</Button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-indigo-600">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm font-medium">Projeler analiz ediliyor...</p>
</div>
)}
{result && (
<div className="bg-muted/50 border border-border rounded-lg p-5 text-sm prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{result}
</div>
)}
</div>
{result && (
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Kapat</Button>
<Button variant="default" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
Yeniden Oluştur
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
+19 -4
View File
@@ -39,22 +39,37 @@ export async function POST(req: Request) {
model = openai('gpt-4o'); 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]; const latestMessage = messages[messages.length - 1];
if (sessionId && latestMessage && latestMessage.role === 'user') { let ragContext = "";
// Sadece metin varsa kaydediyoruz
if (latestMessage.content) { if (latestMessage && latestMessage.role === 'user' && latestMessage.content) {
if (sessionId) {
await supabase.from("chat_messages").insert({ await supabase.from("chat_messages").insert({
session_id: sessionId, session_id: sessionId,
role: "user", role: "user",
content: latestMessage.content, 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. 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. 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. Gerektiğinde araçları (tools) kullanarak sistemden güncel verileri çek ve doğrudan veri ekle.
${ragContext}
Aşağıdaki yeteneklere sahipsin: Aşağıdaki yeteneklere sahipsin:
- Finansal verileri listeleyebilir ve yeni finans kaydı (gelir/gider) girebilirsin. - Finansal verileri listeleyebilir ve yeni finans kaydı (gelir/gider) girebilirsin.
- Görevleri okuyabilir ve yeni görevler ekleyebilirsin. - Görevleri okuyabilir ve yeni görevler ekleyebilirsin.
+80
View File
@@ -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 });
}
}
+84
View File
@@ -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 });
}
}
+72
View File
@@ -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<string, any>,
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;
}
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.68", "@ai-sdk/openai": "^3.0.68",
"@ai-sdk/react": "^3.0.199",
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
+42
View File
@@ -14,6 +14,9 @@ importers:
'@ai-sdk/openai': '@ai-sdk/openai':
specifier: ^3.0.68 specifier: ^3.0.68
version: 3.0.68(zod@4.4.3) 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': '@base-ui/react':
specifier: ^1.5.0 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) 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==} resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'} 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': '@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2364,6 +2373,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4029,6 +4042,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} 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: tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -4048,6 +4066,10 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4349,6 +4371,16 @@ snapshots:
dependencies: dependencies:
json-schema: 0.4.0 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': {} '@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
@@ -6501,6 +6533,8 @@ snapshots:
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
@@ -8458,6 +8492,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} 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: {} tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {} tailwind-merge@3.6.0: {}
@@ -8470,6 +8510,8 @@ snapshots:
tapable@2.3.3: {} tapable@2.3.3: {}
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
@@ -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);
@@ -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;
$$;