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 { 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() {
<Button
type="submit"
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'}`}
disabled={!input.trim()}
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()}
>
<Send className="h-4 w-4 ml-0.5" />
</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")),
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);
+233 -228
View File
@@ -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 (
<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="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
Freelancer operasyonu
CRM & Operasyon
</div>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Müşteriler
CRM & Müşteriler
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Çalıştığın müşterileri, iletişim bilgilerini ve temel durumunu tek
ekrandan yönet.
Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet.
</p>
</div>
</div>
@@ -118,25 +139,25 @@ export function ClientsClient({
<div className="grid gap-3 md:grid-cols-4">
<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()}
icon={UserCheck}
iconClassName="bg-emerald-50 text-emerald-700"
/>
<StatCard
label="Duraklatıldı"
value={pausedCount.toString()}
icon={PauseCircle}
iconClassName="bg-amber-50 text-amber-700"
label="Bekleyen Follow-up"
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={Clock}
iconClassName="bg-rose-50 text-rose-700"
/>
<StatCard
label="Arşiv"
value={archivedCount.toString()}
icon={Archive}
iconClassName="bg-zinc-100 text-zinc-700"
/>
<StatCard
label="Kayıtlı gelir"
label="Kayıtlı Gelir"
value={formatCurrency(totalRevenue)}
description="Ödenmiş gelir işlemleri"
icon={Wallet}
@@ -144,59 +165,112 @@ export function ClientsClient({
/>
</div>
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">
Müşteri listesi
</h2>
<p className="text-sm text-muted-foreground">
{filteredClients.length} kayıt görüntüleniyor.
</p>
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Müşteri, firma, e-posta veya not ara"
className="md:max-w-sm"
/>
</div>
<Tabs defaultValue="pipeline" className="w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<TabsList>
<TabsTrigger value="pipeline">Pipeline (Kanban)</TabsTrigger>
<TabsTrigger value="list">Müşteri Listesi</TabsTrigger>
</TabsList>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Müşteri, firma, e-posta veya not ara"
className="md:max-w-sm"
/>
</div>
{filteredClients.length > 0 ? (
<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">
<span>Müşteri</span>
<span>İletişim</span>
<span>Durum</span>
<span>Projeler</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y divide-border">
{filteredClients.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</div>
</div>
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
<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 ? (
<div className="overflow-hidden rounded-sm border border-border">
<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>İletişim</span>
<span>Aşama</span>
<span>Follow-up</span>
<span>Projeler</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y divide-border">
{filteredClients.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</div>
</div>
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
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 (
<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="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">
{getInitials(client.name)}
</div>
<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">
{client.company_name || "Firma bilgisi yok"}
</div>
@@ -217,43 +291,40 @@ function ClientRow({ client }: { client: ClientListItem }) {
<span className="truncate">{client.phone}</span>
</Link>
) : 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 ? (
<span>İletişim bilgisi yok</span>
) : null}
</div>
<div>
<Badge className={statusClasses[client.status]}>
{statusLabels[client.status]}
<Badge className={stage.color}>
{stage.label}
</Badge>
</div>
<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>
<div className="flex justify-start gap-2 lg:justify-end">
<ClientDialog mode="edit" client={client} />
{client.status !== "archived" ? (
<form action={archiveClientRecord}>
<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>
</form>
) : null}
<Link href={`/clients/${client.id}`}>
<Button variant="ghost" className="h-9 w-9 p-0">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<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>
);
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={mode === "create" ? "default" : "outline"}
className="h-9 min-w-24 gap-2 px-3"
>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Müşteri ekle" : "Düzenle"}
</Button>
{trigger || (
<Button
variant={mode === "create" ? "default" : "outline"}
className="h-9 min-w-24 gap-2 px-3"
>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Müşteri ekle" : "Düzenle"}
</Button>
)}
</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">
{client ? <input type="hidden" name="id" value={client.id} /> : null}
<DialogHeader>
@@ -300,8 +375,7 @@ function ClientDialog({
{mode === "create" ? "Yeni müşteri" : "Müşteriyi düzenle"}
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -326,28 +400,70 @@ function ClientDialog({
function ClientFormFields({ client }: { client?: ClientListItem }) {
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor={`name-${client?.id || "new"}`}>Müşteri adı</Label>
<Input
id={`name-${client?.id || "new"}`}
name="name"
defaultValue={client?.name || ""}
required
placeholder="Örn. Acme Corp"
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`name-${client?.id || "new"}`}>Müşteri adı</Label>
<Input
id={`name-${client?.id || "new"}`}
name="name"
defaultValue={client?.name || ""}
required
placeholder="Örn. Acme Corp"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`company-${client?.id || "new"}`}>Firma / marka adı</Label>
<Input
id={`company-${client?.id || "new"}`}
name="company_name"
defaultValue={client?.company_name || ""}
placeholder="Opsiyonel"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`company-${client?.id || "new"}`}>Firma / marka adı</Label>
<Input
id={`company-${client?.id || "new"}`}
name="company_name"
defaultValue={client?.company_name || ""}
placeholder="Opsiyonel"
/>
<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-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">
<Label htmlFor={`email-${client?.id || "new"}`}>E-posta</Label>
<Input
@@ -368,55 +484,22 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`website-${client?.id || "new"}`}>Web sitesi</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>
<Label htmlFor={`notes-${client?.id || "new"}`}>Genel Notlar</Label>
<Textarea
id={`notes-${client?.id || "new"}`}
name="notes"
defaultValue={client?.notes || ""}
placeholder="İletişim notları, beklentiler, özel bilgiler..."
rows={4}
rows={3}
/>
</div>
</div>
);
}
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 (
<Input
id={id}
@@ -429,43 +512,7 @@ function PhoneInput({
);
}
function WebsiteInput({
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;
}) {
function StatCard({ label, value, description, icon: Icon, iconClassName }: { label: string; value: string; description?: string; icon: LucideIcon; iconClassName: string; }) {
return (
<Card>
<CardContent className="p-4">
@@ -473,9 +520,7 @@ function StatCard({
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
{description ? (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
) : null}
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${iconClassName}`}>
<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"}
</h3>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{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.
</p>
</div>
);
}
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);
}
+5 -1
View File
@@ -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),
+84 -2
View File
@@ -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.
</p>
</div>
<div className="flex gap-2">
<AIFinanceDialog />
<FinanceDialog mode="create" clients={clients} projects={projects} />
</div>
<FinanceDialog mode="create" clients={clients} projects={projects} />
</div>
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
@@ -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[]) {
const totals = new Map<string, number>();
for (const transaction of transactions) {
+89 -1
View File
@@ -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) {
</div>
</div>
<ProjectDialog mode="create" clients={clients} />
<div className="flex gap-2">
<AIProjectRiskDialog />
<ProjectDialog mode="create" clients={clients} />
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
@@ -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<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');
}
// 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.
+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 });
}
}