feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { GraduationCap } from '@/lib/icons';
|
||||
import { AcademyClient } from "@/components/academy/academy-client";
|
||||
|
||||
export const metadata = { title: "Akademi | KovakEmlak" };
|
||||
|
||||
export default function AcademyPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary p-2 rounded-lg">
|
||||
<GraduationCap className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Akademi</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Her modülün turunu başlatarak sistemi adım adım öğrenin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AcademyClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Phone, Envelope, CalendarCheck, Tag } from '@/lib/icons';
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getCustomer } from "@/lib/appwrite/customer-queries";
|
||||
import {
|
||||
DATABASE_ID, TABLES,
|
||||
CUSTOMER_TYPE_LABELS, CUSTOMER_STAGE_LABELS, CUSTOMER_SOURCE_LABELS,
|
||||
ACTIVITY_TYPE_LABELS, PROPERTY_TYPE_LABELS, LISTING_TYPE_LABELS,
|
||||
type Activity, type CustomerSearch, type PropertyMatch, type Property,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
ilk_temas: "bg-slate-100 text-slate-700",
|
||||
aktif_arama: "bg-blue-100 text-blue-700",
|
||||
teklif: "bg-amber-100 text-amber-700",
|
||||
sozlesme: "bg-purple-100 text-purple-700",
|
||||
kapandi: "bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export default async function CustomerDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const customer = await getCustomer(id, ctx.tenantId);
|
||||
if (!customer) notFound();
|
||||
|
||||
const [activitiesRes, searchesRes, matchesRes] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.activities,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSearches,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
queries: [
|
||||
Query.equal("customerId", id),
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("score"),
|
||||
Query.limit(20),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const activities = JSON.parse(JSON.stringify(activitiesRes.rows)) as Activity[];
|
||||
const searches = JSON.parse(JSON.stringify(searchesRes.rows)) as CustomerSearch[];
|
||||
const matches = JSON.parse(JSON.stringify(matchesRes.rows)) as PropertyMatch[];
|
||||
|
||||
// Fetch matched properties for display
|
||||
const matchedPropertyIds = [...new Set(matches.map((m) => m.propertyId))];
|
||||
const propertiesMap: Record<string, Property> = {};
|
||||
if (matchedPropertyIds.length > 0) {
|
||||
const propsRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.properties,
|
||||
queries: [
|
||||
Query.equal("$id", matchedPropertyIds.slice(0, 25)),
|
||||
Query.limit(25),
|
||||
],
|
||||
});
|
||||
for (const row of propsRes.rows) {
|
||||
const p = row as unknown as Property;
|
||||
propertiesMap[p.$id] = p;
|
||||
}
|
||||
}
|
||||
|
||||
const stageKey = customer.stage ?? "ilk_temas";
|
||||
|
||||
function parseJsonList(json?: string | null): string[] {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json) as string[]; } catch { return [json]; }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
{/* Back */}
|
||||
<div>
|
||||
<Link href="/customers" className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm">
|
||||
<ArrowLeft className="size-4" />
|
||||
Müşteriler
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">{customer.name}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline">{CUSTOMER_TYPE_LABELS[customer.type] ?? customer.type}</Badge>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${STAGE_COLORS[stageKey] ?? STAGE_COLORS.ilk_temas}`}>
|
||||
{CUSTOMER_STAGE_LABELS[stageKey as keyof typeof CUSTOMER_STAGE_LABELS] ?? stageKey}
|
||||
</span>
|
||||
{customer.source && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{CUSTOMER_SOURCE_LABELS[customer.source] ?? customer.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/customers"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Düzenle →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Left: info + searches + activities */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Contact card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">İletişim</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{customer.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="size-4 text-muted-foreground shrink-0" />
|
||||
<a href={`tel:${customer.phone}`} className="hover:underline">{customer.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Envelope className="size-4 text-muted-foreground shrink-0" />
|
||||
<a href={`mailto:${customer.email}`} className="hover:underline">{customer.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{customer.nextFollowUpDate && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CalendarCheck className="size-4 text-muted-foreground shrink-0" />
|
||||
<span>Takip: {new Date(customer.nextFollowUpDate).toLocaleDateString("tr-TR", { day: "numeric", month: "long", year: "numeric" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{!customer.phone && !customer.email && !customer.nextFollowUpDate && (
|
||||
<p className="text-sm text-muted-foreground">İletişim bilgisi yok.</p>
|
||||
)}
|
||||
{customer.notes && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">{customer.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MagnifyingGlass criteria */}
|
||||
{searches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Arama Kriterleri ({searches.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{searches.map((s) => (
|
||||
<div key={s.$id} className="rounded-lg border p-3 text-sm space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{s.listingType ? (s.listingType === "satilik" ? "Satılık" : "Kiralık") : "Tümü"}
|
||||
{parseJsonList(s.propertyTypes).length > 0 && ` · ${parseJsonList(s.propertyTypes).join(", ")}`}
|
||||
</span>
|
||||
<Badge variant={s.isActive ? "default" : "secondary"} className="text-xs">
|
||||
{s.isActive ? "Aktif" : "Pasif"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{parseJsonList(s.roomCounts).length > 0 && (
|
||||
<span>Oda: {parseJsonList(s.roomCounts).join(", ")}</span>
|
||||
)}
|
||||
{(s.minPrice || s.maxPrice) && (
|
||||
<span>Fiyat: {s.minPrice ? s.minPrice.toLocaleString("tr-TR") : "–"} – {s.maxPrice ? s.maxPrice.toLocaleString("tr-TR") : "–"} ₺</span>
|
||||
)}
|
||||
{(s.minM2 || s.maxM2) && (
|
||||
<span>m²: {s.minM2 ?? "–"}–{s.maxM2 ?? "–"}</span>
|
||||
)}
|
||||
{parseJsonList(s.cities).length > 0 && (
|
||||
<span>Şehir: {parseJsonList(s.cities).join(", ")}</span>
|
||||
)}
|
||||
{parseJsonList(s.districts).length > 0 && (
|
||||
<span>İlçe: {parseJsonList(s.districts).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
{s.notes && <p className="text-xs text-muted-foreground italic">{s.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
{activities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Aktiviteler ({activities.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y px-0">
|
||||
{activities.map((a) => (
|
||||
<div key={a.$id} className="flex items-start gap-3 px-6 py-3 text-sm">
|
||||
<div className="mt-0.5">
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{a.title}</p>
|
||||
{a.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{a.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: matches */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">
|
||||
Eşleşen İlanlar
|
||||
{matches.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal text-xs">({matches.length})</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Henüz eşleşme yok.</p>
|
||||
) : (
|
||||
matches.map((m) => {
|
||||
const p = propertiesMap[m.propertyId];
|
||||
const score = m.score ?? 0;
|
||||
const scoreColor =
|
||||
score >= 80 ? "text-green-600" : score >= 60 ? "text-blue-600" : score >= 40 ? "text-amber-600" : "text-gray-400";
|
||||
return (
|
||||
<div key={m.$id} className="rounded-lg border p-2.5 text-sm space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium truncate text-xs leading-snug">
|
||||
{p?.title ?? m.propertyId}
|
||||
</p>
|
||||
<span className={`text-xs font-bold ${scoreColor}`}>{score}</span>
|
||||
</div>
|
||||
{p && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
{p.city ? ` · ${p.city}` : ""}
|
||||
{p.price ? ` · ${p.price.toLocaleString("tr-TR")} ₺` : ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[10px] ${m.notified ? "text-muted-foreground" : "text-amber-600 font-medium"}`}>
|
||||
{m.notified ? "Bildirildi" : "Bekliyor"}
|
||||
</span>
|
||||
{p && (
|
||||
<Link href={`/properties/${p.$id}`} className="text-[10px] text-primary hover:underline">
|
||||
Detay →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
@@ -28,16 +29,19 @@ export function DashboardShell({
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
pendingMatchCount = 0,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
pendingMatchCount?: number;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
|
||||
return (
|
||||
<IconContext.Provider value={{ weight: "bold" }}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
@@ -58,14 +62,11 @@ export function DashboardShell({
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
</>
|
||||
@@ -73,11 +74,7 @@ export function DashboardShell({
|
||||
<>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
<AppSidebar
|
||||
@@ -86,6 +83,7 @@ export function DashboardShell({
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -97,5 +95,6 @@ export function DashboardShell({
|
||||
initialPrefs={initialPrefs}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,291 +1,232 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Line, LineChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Cell,
|
||||
} from "recharts";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
ChartContainer, ChartTooltip, type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
export const description = "An interactive area chart"
|
||||
type View = "trend" | "dagilim";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
const PIE_COLORS = [
|
||||
"hsl(221, 83%, 53%)",
|
||||
"hsl(142, 71%, 45%)",
|
||||
"hsl(38, 92%, 50%)",
|
||||
"hsl(280, 68%, 58%)",
|
||||
"hsl(10, 80%, 55%)",
|
||||
"hsl(200, 65%, 50%)",
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
ilanSayisi: { label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||
musteriSayisi: { label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||
aktiviteSayisi: { label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
const SERIES = [
|
||||
{ dataKey: "ilanSayisi", label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||
{ dataKey: "musteriSayisi", label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||
{ dataKey: "aktiviteSayisi", label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||
] as const;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
export function ChartAreaInteractive({
|
||||
ilanTrend, musteriTrend, aktiviteTrend, portfoyDagilim,
|
||||
}: {
|
||||
ilanTrend: { ay: string; ilanSayisi: number }[];
|
||||
musteriTrend: { ay: string; musteriSayisi: number }[];
|
||||
aktiviteTrend: { ay: string; aktiviteSayisi: number }[];
|
||||
portfoyDagilim: { tip: string; label: string; sayi: number }[];
|
||||
}) {
|
||||
const [view, setView] = useState<View>("trend");
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
const trendData = useMemo(() =>
|
||||
ilanTrend.map((item, i) => ({
|
||||
ay: item.ay,
|
||||
ilanSayisi: item.ilanSayisi,
|
||||
musteriSayisi: musteriTrend[i]?.musteriSayisi ?? 0,
|
||||
aktiviteSayisi: aktiviteTrend[i]?.aktiviteSayisi ?? 0,
|
||||
})),
|
||||
[ilanTrend, musteriTrend, aktiviteTrend]
|
||||
);
|
||||
|
||||
const dagilimTotal = portfoyDagilim.reduce((s, d) => s + d.sayi, 0);
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
/*
|
||||
* Mobil: kart doğal yüksekliğinde (overflow: hidden her iki eksen).
|
||||
* Desktop: lg:h-full ile grid item'ı tamamen doldurur (items-stretch ile eşit yükseklik).
|
||||
*/
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="pb-2 shrink-0">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<CardTitle>Portföy Analitiği</CardTitle>
|
||||
<CardDescription>
|
||||
{view === "trend"
|
||||
? "Son 6 ay — ilan, müşteri ve aktivite"
|
||||
: "Aktif portföy dağılımı"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex rounded-lg border text-xs overflow-hidden shrink-0">
|
||||
{(["trend", "dagilim"] as View[]).map((v, i) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 font-medium transition-colors ${i > 0 ? "border-l" : ""} ${
|
||||
view === v
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{v === "trend" ? "Trend" : "Dağılım"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{view === "trend" && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{SERIES.map((s) => (
|
||||
<span key={s.dataKey} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
|
||||
{/*
|
||||
* CardContent:
|
||||
* - Mobil: padding normal, yükseklik = içerik (chart 180px sabit).
|
||||
* - Desktop: lg:flex-1 lg:min-h-0 ile kalan alanı doldurur.
|
||||
* min-h-0 zorunlu: flex-1 tek başına overflow'u engellemez.
|
||||
*/}
|
||||
<CardContent className="px-2 sm:px-4 pb-4 lg:flex-1 lg:min-h-0">
|
||||
{view === "trend" ? (
|
||||
/*
|
||||
* h-[180px]: mobilde sabit yükseklik → Recharts collapse etmez.
|
||||
* lg:h-full: desktop'ta CardContent'in tamamını doldurur.
|
||||
*/
|
||||
<ChartContainer config={chartConfig} className="h-[180px] lg:h-full w-full">
|
||||
<LineChart data={trendData} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="ay"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md space-y-1">
|
||||
<p className="font-medium text-xs text-muted-foreground mb-1.5">{label}</p>
|
||||
{payload.map((p) => {
|
||||
const s = SERIES.find((x) => x.dataKey === p.dataKey);
|
||||
return (
|
||||
<div key={p.dataKey} className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s?.color }} />
|
||||
<span className="flex-1">{s?.label}</span>
|
||||
<span className="font-semibold tabular-nums">{p.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{SERIES.map((s) => (
|
||||
<Line
|
||||
key={s.dataKey}
|
||||
type="monotone"
|
||||
dataKey={s.dataKey}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: s.color, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: s.color, strokeWidth: 0 }}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-center h-[180px] lg:h-full">
|
||||
{portfoyDagilim.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground w-full text-center py-8">
|
||||
Henüz aktif ilan yok
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<PieChart width={150} height={150}>
|
||||
<Pie
|
||||
data={portfoyDagilim}
|
||||
dataKey="sayi"
|
||||
nameKey="label"
|
||||
cx={75} cy={75}
|
||||
innerRadius={46}
|
||||
outerRadius={68}
|
||||
paddingAngle={2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{portfoyDagilim.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const d = payload[0].payload as { label: string; sayi: number };
|
||||
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-sm">
|
||||
<p className="font-medium">{d.label}</p>
|
||||
<p className="text-muted-foreground">{d.sayi} ilan · %{pct}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 w-full space-y-2.5 overflow-y-auto">
|
||||
{portfoyDagilim.map((d, i) => {
|
||||
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||
return (
|
||||
<div key={d.tip}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} />
|
||||
<span className="text-sm flex-1 min-w-0 truncate">{d.label}</span>
|
||||
<span className="text-sm font-semibold tabular-nums">{d.sayi}</span>
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">%{pct}</span>
|
||||
</div>
|
||||
<div className="ml-4 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct}%`, backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
|
||||
export function DashboardCarousel({
|
||||
children,
|
||||
className,
|
||||
count,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
count: number;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const total = el.scrollWidth - el.clientWidth;
|
||||
if (total <= 0) return;
|
||||
setActive(Math.round((el.scrollLeft / total) * (count - 1)));
|
||||
}, [count]);
|
||||
|
||||
const scrollTo = (i: number) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const total = el.scrollWidth - el.clientWidth;
|
||||
el.scrollTo({ left: (total / (count - 1)) * i, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={scrollRef} onScroll={onScroll} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex justify-center gap-1.5 mt-2 lg:hidden">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Bölüm ${i + 1}`}
|
||||
onClick={() => scrollTo(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-200 ${
|
||||
i === active
|
||||
? "w-4 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,19 +21,19 @@ import {
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
CircleCheckBig,
|
||||
EllipsisVertical,
|
||||
GripVertical,
|
||||
Columns2,
|
||||
Loader,
|
||||
CaretDown,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
CaretDoubleLeft,
|
||||
CaretDoubleRight,
|
||||
CheckCircle,
|
||||
DotsThreeVertical,
|
||||
DotsSixVertical,
|
||||
Columns,
|
||||
CircleNotch,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
} from "lucide-react"
|
||||
TrendUp,
|
||||
} from '@/lib/icons'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
@@ -121,7 +121,7 @@ function DragHandle({ id }: { id: number }) {
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent cursor-move"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground size-3" />
|
||||
<DotsSixVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -184,9 +184,9 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.status === "Done" ? (
|
||||
<CircleCheckBig className="text-green-500 dark:text-green-400" />
|
||||
<CheckCircle className="text-green-500 dark:text-green-400" />
|
||||
) : (
|
||||
<Loader />
|
||||
<CircleNotch />
|
||||
)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
@@ -286,7 +286,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8 cursor-pointer"
|
||||
size="icon"
|
||||
>
|
||||
<EllipsisVertical />
|
||||
<DotsThreeVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -629,7 +629,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft />
|
||||
<CaretDoubleLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -639,7 +639,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft />
|
||||
<CaretLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -649,7 +649,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight />
|
||||
<CaretRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -659,7 +659,7 @@ export function DataTable({
|
||||
disabled={!currentTable.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight />
|
||||
<CaretDoubleRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -705,10 +705,10 @@ export function DataTable({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<Columns2 />
|
||||
<Columns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<ChevronDown />
|
||||
<CaretDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -838,7 +838,7 @@ export function DataTable({
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft />
|
||||
<CaretDoubleLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -848,7 +848,7 @@ export function DataTable({
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft />
|
||||
<CaretLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -858,7 +858,7 @@ export function DataTable({
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight />
|
||||
<CaretRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -868,7 +868,7 @@ export function DataTable({
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight />
|
||||
<CaretDoubleRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,7 +992,7 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<TrendingUp className="size-4" />
|
||||
<TrendUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Phone, Envelope, CalendarCheck } from '@/lib/icons';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CUSTOMER_STAGE_LABELS, CUSTOMER_TYPE_LABELS, type Customer } from "@/lib/appwrite/schema";
|
||||
|
||||
function isOverdue(date: string) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return new Date(date) < today;
|
||||
}
|
||||
|
||||
export function FollowUpWidget({ customers }: { customers: Customer[] }) {
|
||||
const empty = customers.length === 0;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CalendarCheck className={`size-4 ${empty ? "text-muted-foreground" : "text-amber-500"}`} />
|
||||
Bugünkü Takipler
|
||||
{!empty && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">{customers.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-3 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6 px-4">
|
||||
Bekleyen takip yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{customers.map((c) => {
|
||||
const overdue = c.nextFollowUpDate ? isOverdue(c.nextFollowUpDate) : false;
|
||||
return (
|
||||
<li key={c.$id} className="flex items-center gap-3 px-6 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
|
||||
{c.stage ? ` · ${CUSTOMER_STAGE_LABELS[c.stage] ?? c.stage}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{overdue && (
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
Gecikti
|
||||
</span>
|
||||
)}
|
||||
{c.phone && (
|
||||
<a
|
||||
href={`tel:${c.phone}`}
|
||||
title={c.phone}
|
||||
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
<Phone className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{c.email && (
|
||||
<a
|
||||
href={`mailto:${c.email}`}
|
||||
title={c.email}
|
||||
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
<Envelope className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
import { ChatCircle, FileText, Eye, Phone, Note } from '@/lib/icons';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Activity } from "@/lib/appwrite/schema";
|
||||
|
||||
const typeConfig: Record<string, { label: string; icon: React.ElementType; variant: "default" | "secondary" | "outline" }> = {
|
||||
gorusme: { label: "Görüşme", icon: ChatCircle, variant: "default" },
|
||||
teklif: { label: "Teklif", icon: FileText, variant: "secondary" },
|
||||
ziyaret: { label: "Ziyaret", icon: Eye, variant: "outline" },
|
||||
arama: { label: "Arama", icon: Phone, variant: "outline" },
|
||||
not: { label: "Not", icon: Note, variant: "outline" },
|
||||
};
|
||||
|
||||
export function RecentActivities({ activities }: { activities: Activity[] }) {
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base">Son Aktiviteler</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Mobil: içerik sayfayı scroll ettirir. Desktop: kart içinde kalır. */}
|
||||
<CardContent className="px-4 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Henüz aktivite yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{activities.map((a) => {
|
||||
const cfg = typeConfig[a.type] ?? typeConfig.not;
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<li key={a.$id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 rounded-md bg-muted p-1.5 shrink-0">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{a.title}</p>
|
||||
<Badge variant={cfg.variant} className="text-xs px-1.5 py-0 shrink-0">
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{a.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
||||
{a.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(a.$createdAt), { addSuffix: true, locale: tr })}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Property } from "@/lib/appwrite/schema";
|
||||
|
||||
const statusDot: Record<string, { color: string; label: string }> = {
|
||||
aktif: { color: "bg-emerald-500", label: "Aktif" },
|
||||
pasif: { color: "bg-zinc-400", label: "Pasif" },
|
||||
satildi: { color: "bg-blue-500", label: "Satıldı" },
|
||||
kiralandit: { color: "bg-orange-400", label: "Kiralandı" },
|
||||
};
|
||||
|
||||
const listingLabel: Record<string, string> = { satilik: "Satılık", kiralik: "Kiralık" };
|
||||
const typeLabel: Record<string, string> = {
|
||||
daire: "Daire", villa: "Villa", arsa: "Arsa",
|
||||
dukkan: "Dükkan", ofis: "Ofis", depo: "Depo",
|
||||
};
|
||||
|
||||
function formatPrice(price: number, currency = "TRY") {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency", currency, maximumFractionDigits: 0,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
export function RecentProperties({ properties }: { properties: Property[] }) {
|
||||
return (
|
||||
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-base">Son Eklenen İlanlar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||
{properties.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center px-4">
|
||||
Henüz ilan yok
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{properties.map((p) => {
|
||||
const dot = statusDot[p.status] ?? { color: "bg-zinc-400", label: p.status };
|
||||
return (
|
||||
<li key={p.$id}>
|
||||
<Link
|
||||
href={`/properties/${p.$id}`}
|
||||
className="flex items-center gap-3 px-6 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
title={dot.label}
|
||||
className={`size-2 rounded-full shrink-0 ${dot.color}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{typeLabel[p.propertyType] ?? p.propertyType} · {listingLabel[p.listingType] ?? p.listingType}
|
||||
{p.city ? ` · ${p.city}` : ""}
|
||||
{p.roomCount ? ` · ${p.roomCount}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{formatPrice(p.price, p.currency)}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(p.$createdAt), { addSuffix: true, locale: tr })}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,58 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||
import { Buildings, Users, Lightning, TrendUp } from '@/lib/icons';
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import type { DashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
export function SectionCards({ stats }: { stats: DashboardStats }) {
|
||||
const items = [
|
||||
{
|
||||
label: "Aktif İlan",
|
||||
value: stats.aktifIlanlar,
|
||||
icon: Buildings,
|
||||
sub: [
|
||||
`${stats.satilikAktif} sat`,
|
||||
`${stats.kiralikAktif} kir`,
|
||||
...(stats.rezerveIlanlar > 0 ? [`${stats.rezerveIlanlar} rsv`] : []),
|
||||
].join(" · "),
|
||||
},
|
||||
{
|
||||
label: "Müşteri",
|
||||
value: stats.toplamMusteri,
|
||||
icon: Users,
|
||||
sub: `${stats.aliciMusteri} alıcı · ${stats.kiraciMusteri} kiracı · ${stats.yatirimciMusteri} yat`,
|
||||
},
|
||||
{
|
||||
label: "Bekleyen Eşleşme",
|
||||
value: stats.bekleyenEslesmeler,
|
||||
icon: Lightning,
|
||||
sub: "iletilmemiş bildirim",
|
||||
accent: stats.bekleyenEslesmeler > 0,
|
||||
},
|
||||
{
|
||||
label: "Bu Ay Eklenen",
|
||||
value: stats.buAyIlanlar,
|
||||
icon: TrendUp,
|
||||
sub: "yeni ilan",
|
||||
},
|
||||
];
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <TrendingUp className="size-4" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-px rounded-xl border bg-border overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="bg-card px-4 py-3.5 flex items-center gap-3">
|
||||
<div className={`size-9 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
item.accent
|
||||
? "bg-amber-100 dark:bg-amber-950/40"
|
||||
: "bg-muted"
|
||||
}`}>
|
||||
<item.icon className={`size-4 ${item.accent ? "text-amber-600 dark:text-amber-400" : "text-foreground/60"}`} />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums leading-none">{item.value}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1 leading-none">{item.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-0.5 truncate">{item.sub}</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <TrendingDown className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,100 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Buildings, UserPlus, GitMerge, Plus } from '@/lib/icons';
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getDashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SectionCards } from "./components/section-cards";
|
||||
import { ChartAreaInteractive } from "./components/chart-area-interactive";
|
||||
import { RecentActivities } from "./components/recent-activities";
|
||||
import { RecentProperties } from "./components/recent-properties";
|
||||
import { FollowUpWidget } from "./components/follow-up-widget";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const stats = await getDashboardStats(ctx.tenantId);
|
||||
|
||||
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||
const officeName = ctx.settings?.officeName ?? "Çalışma alanı";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Portföyünüzü ve müşteri aktivitelerini buradan takip edin.
|
||||
</p>
|
||||
// overflow-x-hidden: Recharts SVG ve diğer absolute-positioned elementlerin
|
||||
// yatay taşmasını keser; tooltip'ler kart içinde render olduğu için etkilenmez.
|
||||
<div className="flex-1 space-y-4 px-4 sm:px-6 pt-0 overflow-x-hidden">
|
||||
|
||||
{/* Başlık */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground truncate">{officeName}</p>
|
||||
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/properties">
|
||||
<Buildings className="size-3.5" />
|
||||
<span className="hidden sm:inline">Yeni İlan</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/customers">
|
||||
<UserPlus className="size-3.5" />
|
||||
<span className="hidden sm:inline">Müşteri</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/presentations">
|
||||
<Plus className="size-3.5" />
|
||||
<span className="hidden sm:inline">Sunum</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||
<Link href="/customers/matches">
|
||||
<GitMerge className="size-3.5" />
|
||||
<span className="hidden sm:inline">Eşleşmeler</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Aktif İlanlar</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
{/* İstatistik şeridi */}
|
||||
<div data-tour="dashboard-stats">
|
||||
<SectionCards stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* Grafik + Son Aktiviteler
|
||||
Mobil: tek kolon, alt alta.
|
||||
Desktop (lg): 3+2 kolon, eşit yükseklik (items-stretch). */}
|
||||
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||
<div className="min-w-0 lg:col-span-3">
|
||||
<ChartAreaInteractive
|
||||
ilanTrend={stats.aylikTrend}
|
||||
musteriTrend={stats.aylikMusteriTrend}
|
||||
aktiviteTrend={stats.aylikAktiviteTrend}
|
||||
portfoyDagilim={stats.portfoyDagilim}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Müşteriler</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<p className="text-muted-foreground text-sm">Bekleyen Eşleşmeler</p>
|
||||
<p className="mt-2 text-3xl font-bold">—</p>
|
||||
<div data-tour="dashboard-activity" className="min-w-0 lg:col-span-2">
|
||||
<RecentActivities activities={stats.sonAktiviteler} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Son İlanlar + Bugünkü Takipler */}
|
||||
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||
<div className="min-w-0 lg:col-span-3">
|
||||
<RecentProperties properties={stats.sonIlanlar} />
|
||||
</div>
|
||||
<div data-tour="dashboard-matches" className="min-w-0 lg:col-span-2">
|
||||
<FollowUpWidget customers={stats.takipMusteri} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { Query } from "node-appwrite";
|
||||
import { FinanceClient } from "@/components/finance/finance-client";
|
||||
import type { Deal } from "@/lib/appwrite/schema";
|
||||
|
||||
export default async function FinancePage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const dealQueries = [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
];
|
||||
|
||||
if (ctx.role === "member") {
|
||||
dealQueries.push(Query.equal("agentId", ctx.user.id));
|
||||
}
|
||||
|
||||
const [dealsResult, customers] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.deals,
|
||||
queries: dealQueries,
|
||||
}),
|
||||
listCustomers(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const deals = JSON.parse(JSON.stringify(dealsResult.rows)) as Deal[];
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 lg:px-8">
|
||||
<FinanceClient
|
||||
initialDeals={deals}
|
||||
customers={customers}
|
||||
role={ctx.role}
|
||||
userId={ctx.user.id}
|
||||
userName={ctx.user.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { createSessionClient } from "@/lib/appwrite/server";
|
||||
import { createAdminClient, createSessionClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
@@ -14,6 +16,17 @@ export default async function DashboardLayout({
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
let pendingMatchCount = 0;
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const res = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.propertyMatches,
|
||||
queries: [Query.equal("tenantId", ctx.tenantId), Query.equal("notified", false), Query.limit(1)],
|
||||
});
|
||||
pendingMatchCount = res.total;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
let themePrefs: ThemePrefs = {};
|
||||
try {
|
||||
const { account } = await createSessionClient();
|
||||
@@ -37,7 +50,7 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
@@ -10,18 +8,13 @@ import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
type Property,
|
||||
type PropertyMatch,
|
||||
type Activity,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PropertyMapView } from "@/components/map/property-map-view";
|
||||
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { PropertyDetailClient } from "@/components/properties/property-detail-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -71,216 +64,13 @@ export default async function PropertyDetailPage({ params }: Props) {
|
||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
||||
const imageIds = parseImageIds(property.imageIds);
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
aktif: "bg-green-100 text-green-700",
|
||||
pasif: "bg-gray-100 text-gray-600",
|
||||
satildi: "bg-orange-100 text-orange-700",
|
||||
kiralandit: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/properties"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
İlanlar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{property.title}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{[property.neighborhood, property.district, property.city].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${statusColor[property.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{PROPERTY_STATUS_LABELS[property.status] ?? property.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Photo gallery */}
|
||||
{imageIds.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
imageIds.length === 1
|
||||
? "grid-cols-1"
|
||||
: imageIds.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{imageIds.map((fileId, i) => (
|
||||
<div
|
||||
key={fileId}
|
||||
className={`overflow-hidden rounded-lg border bg-muted ${i === 0 && imageIds.length > 2 ? "col-span-2 row-span-2" : ""}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(fileId, 1200, 900)}
|
||||
alt={`${property.title} fotoğraf ${i + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ maxHeight: i === 0 && imageIds.length > 2 ? "480px" : "240px" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Price */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
{property.price.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{property.currency ?? "TRY"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<Detail label="Emlak tipi" value={PROPERTY_TYPE_LABELS[property.propertyType]} />
|
||||
<Detail label="İlan türü" value={LISTING_TYPE_LABELS[property.listingType]} />
|
||||
{property.roomCount && <Detail label="Oda sayısı" value={property.roomCount} />}
|
||||
{property.netM2 && <Detail label="Net m²" value={`${property.netM2} m²`} />}
|
||||
{property.grossM2 && <Detail label="Brüt m²" value={`${property.grossM2} m²`} />}
|
||||
{property.floor != null && <Detail label="Kat" value={String(property.floor)} />}
|
||||
{property.totalFloors != null && (
|
||||
<Detail label="Top. kat" value={String(property.totalFloors)} />
|
||||
)}
|
||||
{property.buildingAge != null && (
|
||||
<Detail label="Bina yaşı" value={`${property.buildingAge} yıl`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.address && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Adres: </span>
|
||||
{property.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.mapLat != null && property.mapLng != null && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${property.mapLat},${property.mapLng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Google Maps'te aç ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.description && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">Açıklama</h2>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{property.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.mapLat != null && property.mapLng != null && (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold">Konum</h2>
|
||||
<PropertyMapView
|
||||
lat={property.mapLat}
|
||||
lng={property.mapLng}
|
||||
title={property.title}
|
||||
className="h-64 rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
{activities.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">Aktiviteler</h2>
|
||||
<div className="space-y-2">
|
||||
{activities.map((a) => (
|
||||
<div key={a.$id} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">
|
||||
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{a.title}</p>
|
||||
{a.description && (
|
||||
<p className="text-muted-foreground text-xs truncate">{a.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs ml-auto shrink-0">
|
||||
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matches sidebar */}
|
||||
<div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">
|
||||
İlgili Müşteriler
|
||||
{matches.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">({matches.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{matches.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">Eşleşme yok.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{matches.map((m) => (
|
||||
<div key={m.$id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate">
|
||||
{customerMap[m.customerId] ?? m.customerId}
|
||||
</span>
|
||||
<ScoreBadge score={m.score} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{label}: </span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score?: number | null }) {
|
||||
const s = score ?? 0;
|
||||
const color =
|
||||
s >= 80
|
||||
? "bg-green-100 text-green-700"
|
||||
: s >= 60
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: s >= 40
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<PropertyDetailClient
|
||||
property={property}
|
||||
matches={matches}
|
||||
activities={activities}
|
||||
imageIds={imageIds}
|
||||
customerMap={customerMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -65,12 +65,12 @@ export function EmailForm({ currentEmail }: { currentEmail: string }) {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Email'i güncelle
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,12 +39,12 @@ export function NameForm({ currentName }: { currentName: string }) {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { Key, CircleNotch } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -82,12 +82,12 @@ export function PasswordForm() {
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="size-4" />
|
||||
<Key className="size-4" />
|
||||
Şifreyi değiştir
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function AppearanceSettings() {
|
||||
|
||||
<div className="flex space-x-2 mt-12">
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
Save Preferences
|
||||
FloppyDisk Preferences
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, Zap } from "lucide-react";
|
||||
import { Crown, Lightning } from '@/lib/icons';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
||||
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits";
|
||||
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits-shared";
|
||||
|
||||
const LIMIT_LABELS: Record<string, string> = {
|
||||
properties: "İlan",
|
||||
@@ -36,7 +36,7 @@ export function CurrentPlanCard({
|
||||
variant={isPro ? "default" : "secondary"}
|
||||
className="gap-1"
|
||||
>
|
||||
{isPro ? <Crown className="h-3 w-3" /> : <Zap className="h-3 w-3" />}
|
||||
{isPro ? <Crown className="h-3 w-3" /> : <Lightning className="h-3 w-3" />}
|
||||
{isPro ? "Pro" : "Ücretsiz"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Crown, Check, Loader2 } from "lucide-react";
|
||||
import { Crown, Check, CircleNotch } from '@/lib/icons';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { CheckCircle, XCircle } from '@/lib/icons';
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
@@ -44,7 +44,7 @@ export default async function BillingPage({
|
||||
|
||||
{upgraded && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||
Pro plana başarıyla geçtiniz. İyi kullanımlar!
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Github, Slack, Twitter, Zap, Globe, Database, Apple, Chrome, Facebook, Instagram, Dribbble } from "lucide-react"
|
||||
import { GithubLogo, SlackLogo, TwitterLogo, Lightning, Globe, Database, AppleLogo, Browser, FacebookLogo, InstagramLogo, DribbbleLogo } from '@/lib/icons'
|
||||
import { useState } from "react"
|
||||
export default function ConnectionSettings() {
|
||||
// Controlled state for switches
|
||||
@@ -37,9 +37,9 @@ export default function ConnectionSettings() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Apple className="h-8 w-8" />
|
||||
<AppleLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Apple</div>
|
||||
<div className="font-medium">AppleLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Chrome className="h-8 w-8" />
|
||||
<Browser className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Google</div>
|
||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||
@@ -67,9 +67,9 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Github className="h-8 w-8" />
|
||||
<GithubLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Github</div>
|
||||
<div className="font-medium">GithubLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,9 +82,9 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Slack className="h-8 w-8" />
|
||||
<SlackLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Slack</div>
|
||||
<div className="font-medium">SlackLogo</div>
|
||||
<div className="text-sm text-muted-foreground">Communication</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,13 +109,13 @@ export default function ConnectionSettings() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Facebook className="h-8 w-8" />
|
||||
<FacebookLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Facebook
|
||||
FacebookLogo
|
||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on Facebook</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on FacebookLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||
@@ -125,13 +125,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Twitter className="h-8 w-8" />
|
||||
<TwitterLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Twitter
|
||||
TwitterLogo
|
||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on Twitter</div>
|
||||
<div className="text-sm text-muted-foreground">Share updates on TwitterLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||
@@ -141,13 +141,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Instagram className="h-8 w-8" />
|
||||
<InstagramLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Instagram
|
||||
InstagramLogo
|
||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at Instagram</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at InstagramLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||
@@ -157,13 +157,13 @@ export default function ConnectionSettings() {
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Dribbble className="h-8 w-8" />
|
||||
<DribbbleLogo className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Dribbble
|
||||
DribbbleLogo
|
||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at Dribbble</div>
|
||||
<div className="text-sm text-muted-foreground">Stay connected at DribbbleLogo</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||
@@ -186,7 +186,7 @@ export default function ConnectionSettings() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Zap className="h-8 w-8" />
|
||||
<Lightning className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-medium">Zapier</div>
|
||||
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy, Loader2, UserPlus } from "lucide-react";
|
||||
import { Check, Copy, CircleNotch, UserPlus } from '@/lib/icons';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -80,7 +80,7 @@ export function InviteForm() {
|
||||
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Gönderiliyor...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
|
||||
import { DoorOpen, CircleNotch, Trash } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -179,7 +179,7 @@ export function MembersTable({
|
||||
onClick={() => setLeaving(true)}
|
||||
>
|
||||
{busy === "leave" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<DoorOpen className="size-3.5" />
|
||||
)}
|
||||
@@ -195,9 +195,9 @@ export function MembersTable({
|
||||
onClick={() => setRemoving(m)}
|
||||
>
|
||||
{busy === m.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash className="size-3.5" />
|
||||
)}
|
||||
Çıkar
|
||||
</Button>
|
||||
@@ -240,7 +240,7 @@ export function MembersTable({
|
||||
onClick={handleRemove}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
{busy ? <CircleNotch className="size-4 animate-spin" /> : <Trash className="size-4" />}
|
||||
Çıkar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -269,7 +269,7 @@ export function MembersTable({
|
||||
onClick={handleLeave}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
||||
{busy ? <CircleNotch className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
||||
Ayrıl
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useState } from "react";
|
||||
import { Check, Copy, Loader2, X } from "lucide-react";
|
||||
import { Check, Copy, CircleNotch, X } from '@/lib/icons';
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -119,7 +119,7 @@ export function PendingInvitesTable({
|
||||
onClick={() => cancel(inv.id)}
|
||||
>
|
||||
{busy === inv.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<CircleNotch className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<X className="size-3.5" />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Bell, Mail, MessageSquare } from "lucide-react"
|
||||
import { Bell, Envelope, ChatCircle } from '@/lib/icons'
|
||||
|
||||
const notificationsFormSchema = z.object({
|
||||
emailSecurity: z.boolean(),
|
||||
@@ -595,7 +595,7 @@ export default function NotificationSettings() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
<Envelope className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<FormLabel className="font-medium mb-1">Email</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">Receive notifications via email</div>
|
||||
@@ -639,7 +639,7 @@ export default function NotificationSettings() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<ChatCircle className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<FormLabel className="font-medium mb-1">SMS</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">Receive notifications via SMS</div>
|
||||
@@ -659,7 +659,7 @@ export default function NotificationSettings() {
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" className="cursor-pointer">Save Preferences</Button>
|
||||
<Button type="submit" className="cursor-pointer">FloppyDisk Preferences</Button>
|
||||
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Upload } from "lucide-react"
|
||||
import { Upload } from '@/lib/icons'
|
||||
import { useRef, useState } from "react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Logo } from "@/components/logo"
|
||||
@@ -95,7 +95,7 @@ export default function UserSettingsPage() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
<CardTitle>Profile GearSix</CardTitle>
|
||||
<CardDescription>Update your personal information and preferences</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -346,7 +346,7 @@ export default function UserSettingsPage() {
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-start gap-3">
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
Save Changes
|
||||
FloppyDisk Changes
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="cursor-pointer">
|
||||
Cancel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { Buildings, ImageSquare, CircleNotch, Trash, Upload } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -104,7 +104,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
<Buildings className="size-4" />
|
||||
Logo
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -125,7 +125,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||
<Building2 className="size-8 opacity-40" />
|
||||
<Buildings className="size-8 opacity-40" />
|
||||
<span>Henüz logo yok</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -155,7 +155,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
disabled={!canEdit || busy}
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<ImagePlus className="text-muted-foreground size-6" />
|
||||
<ImageSquare className="text-muted-foreground size-6" />
|
||||
<div className="text-sm font-medium">
|
||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
<Button type="submit" disabled={submitDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
@@ -189,9 +189,9 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{removing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
<Trash className="size-4" />
|
||||
)}
|
||||
Kaldır
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Building2, Loader2, Save } from "lucide-react";
|
||||
import { Buildings, CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,7 +43,7 @@ export function WorkspaceSettingsForm({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
<Buildings className="size-4" />
|
||||
Ofis Bilgileri
|
||||
</CardTitle>
|
||||
<CardDescription>Müşterilere ve sunumlarda gösterilecek ofis bilgileri.</CardDescription>
|
||||
@@ -117,12 +117,12 @@ export function WorkspaceSettingsForm({
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
<FloppyDisk className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
export function SidebarOverlay() {
|
||||
const { open, isMobile, toggleSidebar } = useSidebar();
|
||||
if (isMobile || !open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9] bg-black/20"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user