feat: add AI-powered finance and project risk analysis features with pgvector support for RAG embeddings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 iş 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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user