feat(clients): implement client management actions and UI
- Added actions for creating, updating, and archiving client records in `actions.ts`. - Created a new client management UI in `clients-client.tsx` to display and manage clients. - Integrated client data fetching and state management in `page.tsx`. - Updated layout to change fallback user name from "MindSpace Kullanıcısı" to "Cognis Kullanıcısı".
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const CLIENT_STATUSES = ["active", "paused", "archived"] as const;
|
||||||
|
|
||||||
|
function cleanText(value: FormDataEntryValue | null) {
|
||||||
|
const text = typeof value === "string" ? value.trim() : "";
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStatus(value: FormDataEntryValue | null) {
|
||||||
|
const status = typeof value === "string" ? value : "active";
|
||||||
|
return CLIENT_STATUSES.includes(status as (typeof CLIENT_STATUSES)[number])
|
||||||
|
? status
|
||||||
|
: "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanWebsite(value: FormDataEntryValue | null) {
|
||||||
|
const website = cleanText(value)?.replace(/\s/g, "") || null;
|
||||||
|
|
||||||
|
if (!website) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^https?:\/\//i.test(website) ? website : `https://${website}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUserId() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
throw new Error("Müşteri işlemi için giriş yapmış kullanıcı bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supabase, userId: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClientRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const name = cleanText(formData.get("name"));
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Müşteri adı zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from("clients").insert({
|
||||||
|
user_id: userId,
|
||||||
|
name,
|
||||||
|
company_name: cleanText(formData.get("company_name")),
|
||||||
|
email: cleanText(formData.get("email")),
|
||||||
|
phone: cleanText(formData.get("phone")),
|
||||||
|
website: cleanWebsite(formData.get("website")),
|
||||||
|
status: readStatus(formData.get("status")),
|
||||||
|
notes: cleanText(formData.get("notes")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Müşteri eklenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClientRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
const name = cleanText(formData.get("name"));
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
throw new Error("Müşteri güncellemek için müşteri adı ve kayıt kimliği zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("clients")
|
||||||
|
.update({
|
||||||
|
name,
|
||||||
|
company_name: cleanText(formData.get("company_name")),
|
||||||
|
email: cleanText(formData.get("email")),
|
||||||
|
phone: cleanText(formData.get("phone")),
|
||||||
|
website: cleanWebsite(formData.get("website")),
|
||||||
|
status: readStatus(formData.get("status")),
|
||||||
|
notes: cleanText(formData.get("notes")),
|
||||||
|
})
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Müşteri güncellenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveClientRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Arşivlenecek müşteri bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("clients")
|
||||||
|
.update({ status: "archived" })
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Müşteri arşivlenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/clients");
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
archiveClientRecord,
|
||||||
|
createClientRecord,
|
||||||
|
updateClientRecord,
|
||||||
|
} from "@/app/(dashboard)/clients/actions";
|
||||||
|
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "poyraz-ui/molecules";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ExternalLink,
|
||||||
|
Mail,
|
||||||
|
PauseCircle,
|
||||||
|
Pencil,
|
||||||
|
Phone,
|
||||||
|
Plus,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export type ClientListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
company_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
status: "active" | "paused" | "archived";
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
projectCount: number;
|
||||||
|
revenueTotal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
active: "Aktif",
|
||||||
|
paused: "Duraklatıldı",
|
||||||
|
archived: "Arşivlendi",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClasses = {
|
||||||
|
active: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
|
paused: "border-amber-200 bg-amber-50 text-amber-700",
|
||||||
|
archived: "border-zinc-200 bg-zinc-50 text-zinc-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientsClientProps = {
|
||||||
|
clients: ClientListItem[];
|
||||||
|
totalRevenue: number;
|
||||||
|
activeCount: number;
|
||||||
|
pausedCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientsClient({
|
||||||
|
clients,
|
||||||
|
totalRevenue,
|
||||||
|
activeCount,
|
||||||
|
pausedCount,
|
||||||
|
archivedCount,
|
||||||
|
}: ClientsClientProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredClients = normalizedQuery
|
||||||
|
? clients.filter((client) =>
|
||||||
|
[
|
||||||
|
client.name,
|
||||||
|
client.company_name,
|
||||||
|
client.email,
|
||||||
|
client.phone,
|
||||||
|
client.website,
|
||||||
|
client.notes,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
||||||
|
)
|
||||||
|
: clients;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
|
<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
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientDialog mode="create" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Arşiv"
|
||||||
|
value={archivedCount.toString()}
|
||||||
|
icon={Archive}
|
||||||
|
iconClassName="bg-zinc-100 text-zinc-700"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Kayıtlı gelir"
|
||||||
|
value={formatCurrency(totalRevenue)}
|
||||||
|
description="Ödenmiş gelir işlemleri"
|
||||||
|
icon={Wallet}
|
||||||
|
iconClassName="bg-primary/10 text-primary"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientRow({ client }: { client: ClientListItem }) {
|
||||||
|
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="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>
|
||||||
|
<div className="truncate text-sm text-muted-foreground">
|
||||||
|
{client.company_name || "Firma bilgisi yok"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{client.email ? (
|
||||||
|
<Link href={`mailto:${client.email}`} className="flex items-center gap-2 hover:text-primary">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{client.email}</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{client.phone ? (
|
||||||
|
<Link href={`tel:${client.phone}`} className="flex items-center gap-2 hover:text-primary">
|
||||||
|
<Phone className="h-3.5 w-3.5" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-foreground">{client.projectCount}</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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientDialog({
|
||||||
|
mode,
|
||||||
|
client,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
client?: ClientListItem;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const action = mode === "create" ? createClientRecord : updateClientRecord;
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await action(formData);
|
||||||
|
setOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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">
|
||||||
|
<form action={handleSubmit} className="space-y-5">
|
||||||
|
{client ? <input type="hidden" name="id" value={client.id} /> : null}
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ClientFormFields client={client} />
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="gap-2">
|
||||||
|
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||||
|
{isSubmitting
|
||||||
|
? "Kaydediliyor"
|
||||||
|
: mode === "create"
|
||||||
|
? "Müşteriyi ekle"
|
||||||
|
: "Değişiklikleri kaydet"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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 className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`email-${client?.id || "new"}`}>E-posta</Label>
|
||||||
|
<Input
|
||||||
|
id={`email-${client?.id || "new"}`}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={client?.email || ""}
|
||||||
|
placeholder="musteri@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`phone-${client?.id || "new"}`}>Telefon</Label>
|
||||||
|
<PhoneInput
|
||||||
|
id={`phone-${client?.id || "new"}`}
|
||||||
|
name="phone"
|
||||||
|
defaultValue={client?.phone || ""}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<Textarea
|
||||||
|
id={`notes-${client?.id || "new"}`}
|
||||||
|
name="notes"
|
||||||
|
defaultValue={client?.notes || ""}
|
||||||
|
placeholder="İletişim notları, beklentiler, özel bilgiler..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneInput({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
defaultValue,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
defaultValue: string;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
inputMode="tel"
|
||||||
|
placeholder="+90 (5xx) xxx xx xx"
|
||||||
|
onChange={(event) => setValue(formatPhone(event.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${iconClassName}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
|
||||||
|
<Users className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-foreground">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,282 +1,106 @@
|
|||||||
"use client";
|
import { ClientsClient, type ClientListItem } from "@/app/(dashboard)/clients/clients-client";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
import { useState } from "react";
|
type ClientRow = {
|
||||||
import {
|
id: string;
|
||||||
Plus, Search, User, Users, Mail, Phone, Globe,
|
name: string;
|
||||||
MoreHorizontal, MessageSquare, Briefcase,
|
company_name: string | null;
|
||||||
TrendingUp, Star, Clock, X, ChevronRight,
|
email: string | null;
|
||||||
ArrowUpRight, DollarSign, Brain, Shield,
|
phone: string | null;
|
||||||
Activity, CheckCircle2, AlertCircle
|
website: string | null;
|
||||||
} from "lucide-react";
|
status: "active" | "paused" | "archived";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Mock Data
|
type ProjectRow = {
|
||||||
const clients = [
|
client_id: string | null;
|
||||||
{
|
};
|
||||||
id: 1,
|
|
||||||
name: "Acme Corp",
|
|
||||||
contact: "John Doe",
|
|
||||||
email: "john@acme.com",
|
|
||||||
status: "Active",
|
|
||||||
value: "$12,400",
|
|
||||||
projects: 2,
|
|
||||||
health: "Stable",
|
|
||||||
lastContact: "2 days ago",
|
|
||||||
aiInsight: "Excellent relationship. High potential for upsell into the Q3 Marketing Package.",
|
|
||||||
history: [
|
|
||||||
{ type: "Meeting", date: "May 12", note: "Q3 Strategy Review" },
|
|
||||||
{ type: "Payment", date: "May 08", note: "$4,200 received" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Global Tech",
|
|
||||||
contact: "Jane Smith",
|
|
||||||
email: "jane@global.io",
|
|
||||||
status: "Onboarding",
|
|
||||||
value: "$8,500",
|
|
||||||
projects: 1,
|
|
||||||
health: "Critical",
|
|
||||||
lastContact: "1 week ago",
|
|
||||||
aiInsight: "Risk of churn detected. Last communication was 7 days ago. Immediate outreach suggested.",
|
|
||||||
history: [
|
|
||||||
{ type: "Proposal", date: "May 01", note: "Infrastructure Scale" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ id: 3, name: "Nexus Design", contact: "Mike Ross", email: "mike@nexus.com", status: "Active", value: "$42,000", projects: 4, health: "Growth", lastContact: "Today", aiInsight: "Client is expanding rapidly. Consider offering a dedicated project manager role.", history: [] },
|
|
||||||
{ id: 4, name: "Stark Ind.", contact: "Pepper P.", email: "pepper@stark.com", status: "Lead", value: "$0", projects: 0, health: "Neutral", lastContact: "May 14", aiInsight: "Warm lead from the Webflow conference. Interested in AI integration.", history: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ClientsPage() {
|
type FinanceRow = {
|
||||||
const [selectedClient, setSelectedClient] = useState<any>(null);
|
client_id: string | null;
|
||||||
|
amount: number | string;
|
||||||
|
type: "income" | "expense";
|
||||||
|
payment_status: "planned" | "pending" | "paid" | "cancelled";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ClientsPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ data: clientRows }, { data: projectRows }, { data: financeRows }] =
|
||||||
|
await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from("clients")
|
||||||
|
.select("id, name, company_name, email, phone, website, status, notes, created_at")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.order("created_at", { ascending: false }),
|
||||||
|
supabase.from("projects").select("client_id").eq("user_id", user.id),
|
||||||
|
supabase
|
||||||
|
.from("finance_transactions")
|
||||||
|
.select("client_id, amount, type, payment_status")
|
||||||
|
.eq("user_id", user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectCountByClient = countProjectsByClient((projectRows || []) as ProjectRow[]);
|
||||||
|
const revenueByClient = sumRevenueByClient((financeRows || []) as FinanceRow[]);
|
||||||
|
|
||||||
|
const clients: ClientListItem[] = ((clientRows || []) as ClientRow[]).map((client) => ({
|
||||||
|
...client,
|
||||||
|
projectCount: projectCountByClient.get(client.id) || 0,
|
||||||
|
revenueTotal: revenueByClient.get(client.id) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activeCount = clients.filter((client) => client.status === "active").length;
|
||||||
|
const pausedCount = clients.filter((client) => client.status === "paused").length;
|
||||||
|
const archivedCount = clients.filter((client) => client.status === "archived").length;
|
||||||
|
const totalRevenue = clients.reduce((sum, client) => sum + client.revenueTotal, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans space-y-6 pb-12 relative">
|
<ClientsClient
|
||||||
|
clients={clients}
|
||||||
{/* Top Header */}
|
totalRevenue={totalRevenue}
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
|
activeCount={activeCount}
|
||||||
<div className="flex items-center gap-4">
|
pausedCount={pausedCount}
|
||||||
<h1 className="text-lg font-medium text-muted-foreground">
|
archivedCount={archivedCount}
|
||||||
<span className="text-foreground">Network</span> / Strategic Clients
|
|
||||||
</h1>
|
|
||||||
<div className="h-4 w-px bg-white/10" />
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-primary bg-primary/5 px-3 py-1 rounded-sm border border-primary/10">
|
|
||||||
<Users className="h-3 w-3" /> 12 ACTIVE PARTNERSHIPS
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search clients..."
|
|
||||||
className="bg-transparent border-none outline-none text-xs w-48 placeholder:text-muted-foreground/50 text-foreground"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<button className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-5 py-2 rounded-sm text-xs font-black tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-primary/30 active:scale-95">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
ADD CLIENT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CRM Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 shrink-0">
|
|
||||||
<ClientStatCard label="Pipeline Value" value="$128.5k" subtext="+12% this month" icon={DollarSign} />
|
|
||||||
<ClientStatCard label="Avg. Health" value="Stable" subtext="92% Retention" icon={Activity} />
|
|
||||||
<ClientStatCard label="New Leads" value="15" subtext="8 Qualified" icon={TrendingUp} />
|
|
||||||
<div className="bg-primary/5 border border-primary/20 rounded-sm p-6 flex flex-col justify-center relative overflow-hidden group">
|
|
||||||
<div className="absolute -right-4 -top-4 opacity-5 rotate-12 transition-transform group-hover:scale-110"><Brain className="h-20 w-20 text-primary" /></div>
|
|
||||||
<div className="flex items-center gap-2 text-primary font-black uppercase tracking-widest text-[10px] mb-2">
|
|
||||||
<Brain className="h-3.5 w-3.5" /> AI Insight
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] font-bold text-foreground/80 leading-relaxed italic">
|
|
||||||
"High churn risk for Global Tech. Immediate outreach suggested."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Clients List */}
|
|
||||||
<div className="flex-1 overflow-y-auto tiny-scrollbar pr-1">
|
|
||||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm overflow-hidden shadow-2xl">
|
|
||||||
<div className="grid grid-cols-12 gap-4 p-5 border-b border-white/5 bg-[#0F0B15]/50 text-[10px] font-black uppercase tracking-[0.3em] text-muted-foreground">
|
|
||||||
<div className="col-span-4 px-2">Partner Entity</div>
|
|
||||||
<div className="col-span-2">Relationship</div>
|
|
||||||
<div className="col-span-2">Strategic Value</div>
|
|
||||||
<div className="col-span-2">Engagement</div>
|
|
||||||
<div className="col-span-2 text-right">Actions</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-white/5">
|
|
||||||
{clients.map((client) => (
|
|
||||||
<motion.div
|
|
||||||
key={client.id}
|
|
||||||
onClick={() => setSelectedClient(client)}
|
|
||||||
className="grid grid-cols-12 gap-4 p-6 items-center hover:bg-white/[0.02] transition-all group cursor-pointer border-l-2 border-transparent hover:border-primary"
|
|
||||||
>
|
|
||||||
<div className="col-span-4 flex items-center gap-4">
|
|
||||||
<div className="h-10 w-10 rounded-sm bg-[#150F1D] border border-white/5 flex items-center justify-center font-black text-primary text-xs uppercase tracking-tighter shadow-inner">
|
|
||||||
{client.name.split(' ').map(n => n[0]).join('')}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-black group-hover:text-primary transition-colors truncate">{client.name}</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground font-bold tracking-widest mt-0.5">{client.contact}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className={`text-[9px] font-black uppercase tracking-[0.2em] px-2.5 py-1 rounded-sm border ${
|
|
||||||
client.health === 'Growth' ? 'text-emerald-400 border-emerald-500/20 bg-emerald-500/5' :
|
|
||||||
client.health === 'Stable' ? 'text-blue-400 border-blue-500/20 bg-blue-500/5' :
|
|
||||||
client.health === 'Critical' ? 'text-rose-400 border-rose-500/20 bg-rose-500/5' : 'text-muted-foreground border-white/10'
|
|
||||||
}`}>
|
|
||||||
{client.health}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2 flex flex-col">
|
|
||||||
<span className="text-sm font-black text-foreground">{client.value}</span>
|
|
||||||
<span className="text-[9px] text-muted-foreground font-bold uppercase tracking-widest">{client.projects} Active Projects</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2 flex flex-col">
|
|
||||||
<span className="text-xs font-bold text-foreground">{client.lastContact}</span>
|
|
||||||
<span className="text-[9px] text-muted-foreground font-bold uppercase tracking-widest mt-0.5">Last Touchpoint</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2 flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
|
||||||
<button className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><MessageSquare className="h-4 w-4" /></button>
|
|
||||||
<button className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><MoreHorizontal className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Client Detail Sheet */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedClient && (
|
|
||||||
<>
|
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setSelectedClient(null)} className="fixed inset-0 bg-black/80 backdrop-blur-md z-[100]" />
|
|
||||||
<motion.div initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="fixed top-0 right-0 h-full w-full max-w-2xl bg-[#0A0710] border-l border-white/5 z-[101] shadow-2xl flex flex-col">
|
|
||||||
|
|
||||||
<div className="p-10 border-b border-white/5 flex items-center justify-between bg-primary/5">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="h-16 w-16 rounded-sm bg-primary/10 border border-primary/20 flex items-center justify-center font-black text-primary text-xl uppercase shadow-xl">
|
|
||||||
{selectedClient.name.split(' ').map((n: string) => n[0]).join('')}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h2 className="text-3xl font-black tracking-tighter text-foreground">{selectedClient.name}</h2>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[10px] font-black text-primary uppercase tracking-widest">{selectedClient.contact}</span>
|
|
||||||
<div className="w-1 h-1 rounded-full bg-white/20" />
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{selectedClient.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedClient(null)} className="p-3 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><X className="h-6 w-6" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-10 space-y-12 tiny-scrollbar">
|
|
||||||
|
|
||||||
{/* AI Client Pulse */}
|
|
||||||
<div className="rounded-sm border border-primary/20 bg-primary/5 p-8 space-y-4 relative overflow-hidden">
|
|
||||||
<div className="absolute -right-4 -top-4 opacity-5">
|
|
||||||
<Brain className="h-24 w-24 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
|
|
||||||
<Brain className="h-4 w-4" /> Client Health Pulse
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm font-medium text-foreground/90 leading-relaxed italic">
|
|
||||||
"{selectedClient.aiInsight}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Financials */}
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm p-6 space-y-2">
|
|
||||||
<div className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">LIFETIME VALUE</div>
|
|
||||||
<div className="text-2xl font-black text-primary">{selectedClient.value}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm p-6 space-y-2">
|
|
||||||
<div className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">ACTIVE DELIVERABLES</div>
|
|
||||||
<div className="text-2xl font-black text-foreground">{selectedClient.projects}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Relationship History */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Relationship Log</h3>
|
|
||||||
<div className="relative space-y-6 pl-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-white/10">
|
|
||||||
{selectedClient.history?.length > 0 ? selectedClient.history.map((log: any, i: number) => (
|
|
||||||
<div key={i} className="relative group">
|
|
||||||
<div className="absolute -left-[23px] top-1 h-3 w-3 rounded-full border-2 border-[#0A0710] bg-primary z-10" />
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold group-hover:text-primary transition-colors">{log.note}</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground mt-1 uppercase font-medium">{log.date} • {log.type}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : <p className="text-xs text-muted-foreground italic">No historical log entries found for this partner.</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="space-y-6 pt-6 border-t border-white/5">
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Strategic Actions</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<ActionButton icon={Briefcase} label="Launch New Project" />
|
|
||||||
<ActionButton icon={DollarSign} label="Generate Invoice" />
|
|
||||||
<ActionButton icon={MessageSquare} label="Relationship Review" />
|
|
||||||
<ActionButton icon={Shield} label="Update Contract" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-10 border-t border-white/5 bg-[#0F0B15]/50 flex gap-4">
|
|
||||||
<button className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground py-5 rounded-sm text-[11px] font-black uppercase tracking-widest shadow-2xl shadow-primary/30 transition-all active:scale-[0.98]">
|
|
||||||
OPEN PARTNER PORTAL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({ icon: Icon, label }: { icon: any, label: string }) {
|
function countProjectsByClient(projects: ProjectRow[]) {
|
||||||
return (
|
const countByClient = new Map<string, number>();
|
||||||
<button className="p-4 rounded-sm bg-[#150F1D] border border-white/5 flex items-center gap-3 group hover:border-primary/20 transition-all text-left">
|
|
||||||
<div className="p-2 bg-white/5 rounded-sm group-hover:bg-primary/10 transition-colors">
|
for (const project of projects) {
|
||||||
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
if (!project.client_id) continue;
|
||||||
</div>
|
countByClient.set(project.client_id, (countByClient.get(project.client_id) || 0) + 1);
|
||||||
<span className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-tight group-hover:text-foreground">{label}</span>
|
}
|
||||||
</button>
|
|
||||||
|
return countByClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumRevenueByClient(transactions: FinanceRow[]) {
|
||||||
|
const revenueByClient = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
if (
|
||||||
|
!transaction.client_id ||
|
||||||
|
transaction.type !== "income" ||
|
||||||
|
transaction.payment_status !== "paid"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
revenueByClient.set(
|
||||||
|
transaction.client_id,
|
||||||
|
(revenueByClient.get(transaction.client_id) || 0) + Number(transaction.amount || 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientStatCard({ label, value, subtext, icon: Icon }: any) {
|
return revenueByClient;
|
||||||
return (
|
|
||||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm p-8 flex items-center gap-6 group relative overflow-hidden shadow-xl">
|
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
|
||||||
<Icon className="h-20 w-20 text-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-white/5 rounded-sm group-hover:bg-primary/10 transition-colors relative z-10">
|
|
||||||
<Icon className="h-6 w-6 text-muted-foreground group-hover:text-primary transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground mb-1">{label}</div>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-black text-foreground tracking-tighter leading-none">{value}</span>
|
|
||||||
<span className="text-[10px] text-emerald-500 font-bold uppercase tracking-widest">{subtext}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default async function DashboardLayout({
|
|||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
: { data: null };
|
: { data: null };
|
||||||
|
|
||||||
const fallbackName = user?.email?.split("@")[0] ?? "MindSpace Kullanıcısı";
|
const fallbackName = user?.email?.split("@")[0] ?? "Cognis Kullanıcısı";
|
||||||
const displayName =
|
const displayName =
|
||||||
[profile?.first_name, profile?.last_name].filter(Boolean).join(" ") ||
|
[profile?.first_name, profile?.last_name].filter(Boolean).join(" ") ||
|
||||||
fallbackName;
|
fallbackName;
|
||||||
|
|||||||
Reference in New Issue
Block a user