feat: emlak CRM iskelet kurulumu

- schema.ts tamamen yeniden yazıldı (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Sidebar emlak modüllerine güncellendi (İlanlar, Müşteriler, Yatırımcılar, Sunumlar, Aktiviteler)
- Eski CRM lib dosyaları temizlendi (finance, invoice, lead, task, software, vs.)
- Yeni modül dizinleri oluşturuldu (stub pages)
- command-search emlak navigasyonuna güncellendi
- site-header temizlendi
- Typecheck: 0 hata (chart.tsx template hariç)
This commit is contained in:
egecankomur
2026-05-05 11:43:29 +03:00
parent 37679e83e6
commit 2f17c342ca
172 changed files with 422 additions and 23862 deletions
+37 -70
View File
@@ -2,16 +2,14 @@
import * as React from "react";
import {
Briefcase,
Calendar,
CheckSquare,
Activity,
Building2,
CreditCard,
FileText,
LayoutDashboard,
Package,
Receipt,
Presentation,
Search,
Settings,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
@@ -37,79 +35,54 @@ const navGroups = [
label: "Genel",
items: [
{
title: "Genel bakış",
title: "Genel Bakış",
url: "/dashboard",
icon: LayoutDashboard,
},
],
},
{
label: "İşletme",
label: "Portföy",
items: [
{
title: "Müşteri Adayları",
url: "/leads",
icon: TrendingUp,
title: "İlanlar",
url: "/properties",
icon: Building2,
},
],
},
{
label: "İlişkiler",
items: [
{
title: "Müşteriler",
url: "/customers",
icon: Users,
},
{
title: "Hizmetler",
url: "/services",
icon: Briefcase,
},
{
title: "Yazılımlarımız",
url: "/software",
icon: Package,
},
],
},
{
label: "Operasyon",
items: [
{
title: "Takvim",
url: "/calendar",
icon: Calendar,
},
{
title: "Görevler",
url: "/tasks",
icon: CheckSquare,
},
],
},
{
label: "Finans",
items: [
{
title: "Gelir / Gider",
url: "/finance",
icon: Wallet,
},
{
title: "Bankalar",
url: "/finance/banks",
icon: Briefcase,
items: [
{ title: "Banka hesapları", url: "/finance/banks" },
{ title: "Krediler", url: "/finance/loans" },
{ title: "Kredi kartları", url: "/finance/cards" },
{ title: "Müşteri Listesi", url: "/customers" },
{ title: "Arama Kriterleri", url: "/customers/searches" },
{ title: "Eşleşmeler", url: "/customers/matches" },
],
},
{
title: "Faturalar",
url: "/invoices",
icon: Receipt,
title: "Yatırımcılar",
url: "/investors",
icon: Wallet,
},
],
},
{
label: "Satış",
items: [
{
title: "Sunumlar",
url: "/presentations",
icon: Presentation,
},
{
title: "Rapor",
url: "/finance/reports",
icon: FileText,
title: "Aktiviteler",
url: "/activities",
icon: Activity,
},
],
},
@@ -117,13 +90,12 @@ const navGroups = [
label: "Hesap",
items: [
{
title: "Çalışma alanı",
title: "Çalışma Alanı",
url: "/settings/workspace",
icon: Settings,
items: [
{ title: "Şirket bilgileri", url: "/settings/workspace" },
{ title: "Ekip üyeleri", url: "/settings/members" },
{ title: "Faturalama", url: "/settings/billing" },
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
{ title: "Ekip Üyeleri", url: "/settings/members" },
],
},
{
@@ -131,11 +103,6 @@ const navGroups = [
url: "/settings/account",
icon: FileText,
},
{
title: "Plan",
url: "/pricing",
icon: CreditCard,
},
],
},
];
@@ -170,7 +137,7 @@ export function AppSidebar({
</div>
)}
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">İşletmem</span>
<span className="truncate font-medium">KovakEmlak</span>
<span className="text-muted-foreground truncate text-xs">{company.name}</span>
</div>
</Link>
-68
View File
@@ -1,68 +0,0 @@
import Link from "next/link";
import { AlertTriangle, Crown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
RESOURCE_LABELS,
type PlanResource,
type PlanUsage,
} from "@/lib/appwrite/plan-limits";
const SOFT_THRESHOLD = 0.8;
export function UsageBanner({
usage,
resource,
}: {
usage: PlanUsage;
resource: PlanResource;
}) {
if (usage.plan === "pro") return null;
const u = usage.usage[resource];
if (u.limit === Number.POSITIVE_INFINITY) return null;
const ratio = u.used / u.limit;
if (ratio < SOFT_THRESHOLD) return null;
const label = RESOURCE_LABELS[resource];
const reached = u.reached;
return (
<Card
className={
reached
? "border-destructive/40 bg-destructive/5"
: "border-amber-500/40 bg-amber-500/5"
}
>
<CardContent className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle
className={`size-4 mt-0.5 shrink-0 ${
reached ? "text-destructive" : "text-amber-600"
}`}
/>
<div>
<span className="font-medium">
{reached ? "Sınıra ulaşıldı" : "Sınıra yaklaşıyorsun"}:
</span>{" "}
<span className="text-muted-foreground">
{u.used} / {u.limit} {label}.
</span>
{reached && (
<span className="text-muted-foreground"> Yeni {label} eklemek için Pro'ya geç.</span>
)}
</div>
</div>
<Button asChild size="sm" variant={reached ? "default" : "outline"}>
<Link href="/settings/billing">
<Crown className="size-3.5" />
Pro'ya geç
</Link>
</Button>
</CardContent>
</Card>
);
}
+64 -328
View File
@@ -4,18 +4,12 @@ import * as React from "react";
import { useRouter } from "next/navigation";
import { Command as CommandPrimitive } from "cmdk";
import {
Briefcase,
Calendar as CalendarIcon,
CheckSquare,
CircleDollarSign,
FilePlus,
Activity,
Building2,
LayoutDashboard,
Loader2,
Package,
Receipt,
Presentation,
Search,
Settings,
UserPlus,
Users,
Wallet,
type LucideIcon,
@@ -23,12 +17,6 @@ import {
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
globalSearchAction,
type SearchGroup,
type SearchHit,
type SearchResults,
} from "@/lib/appwrite/search-actions";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -41,333 +29,81 @@ const Command = React.forwardRef<
className,
)}
{...props}
shouldFilter={false}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-4">
<Search className="text-muted-foreground mr-2 size-4" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-12 w-full border-none bg-transparent py-3 text-base outline-none",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
type NavItem = { title: string; url: string; icon: LucideIcon };
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[440px] overflow-y-auto overflow-x-hidden p-2", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="text-muted-foreground py-8 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&:not(:first-child)]:mt-2",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
type NavItem = { key: string; title: string; url: string; icon: LucideIcon };
const NAV: NavItem[] = [
{ key: "nav-dashboard", title: "Genel bakış", url: "/dashboard", icon: LayoutDashboard },
{ key: "nav-customers", title: "Müşteriler", url: "/customers", icon: Users },
{ key: "nav-services", title: "Hizmetler", url: "/services", icon: Briefcase },
{ key: "nav-software", title: "Yazılımlarımız", url: "/software", icon: Package },
{ key: "nav-calendar", title: "Takvim", url: "/calendar", icon: CalendarIcon },
{ key: "nav-tasks", title: "Görevler", url: "/tasks", icon: CheckSquare },
{ key: "nav-finance", title: "Gelir / Gider", url: "/finance", icon: Wallet },
{ key: "nav-invoices", title: "Faturalar", url: "/invoices", icon: Receipt },
const NAV_ITEMS: NavItem[] = [
{ title: "Genel Bakış", url: "/dashboard", icon: LayoutDashboard },
{ title: "İlanlar", url: "/properties", icon: Building2 },
{ title: "Müşteriler", url: "/customers", icon: Users },
{ title: "Yatırımcılar", url: "/investors", icon: Wallet },
{ title: "Sunumlar", url: "/presentations", icon: Presentation },
{ title: "Aktiviteler", url: "/activities", icon: Activity },
{ title: "Ofis Ayarları", url: "/settings/workspace", icon: Settings },
];
const QUICK_ACTIONS: NavItem[] = [
{ key: "qa-customer", title: "Yeni müşteri ekle", url: "/customers", icon: UserPlus },
{ key: "qa-invoice", title: "Yeni fatura kes", url: "/invoices", icon: Receipt },
{ key: "qa-task", title: "Yeni görev oluştur", url: "/tasks", icon: FilePlus },
{ key: "qa-event", title: "Takvime etkinlik ekle", url: "/calendar", icon: CalendarIcon },
{ key: "qa-finance", title: "Yeni gelir/gider girişi", url: "/finance", icon: CircleDollarSign },
];
const SETTINGS_NAV: NavItem[] = [
{ key: "set-workspace", title: "Şirket bilgileri", url: "/settings/workspace", icon: Settings },
{ key: "set-members", title: "Ekip üyeleri", url: "/settings/members", icon: Users },
{ key: "set-account", title: "Profil", url: "/settings/account", icon: Settings },
];
const GROUP_LABEL: Record<SearchGroup, string> = {
customers: "Müşteriler",
invoices: "Faturalar",
tasks: "Görevler",
services: "Hizmetler",
software: "Yazılımlar",
events: "Takvim",
finance: "Finans",
};
const GROUP_ICON: Record<SearchGroup, LucideIcon> = {
customers: Users,
invoices: Receipt,
tasks: CheckSquare,
services: Briefcase,
software: Package,
events: CalendarIcon,
finance: Wallet,
};
const GROUP_ORDER: SearchGroup[] = [
"customers",
"invoices",
"tasks",
"events",
"finance",
"services",
"software",
];
interface CommandSearchProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function filterNav(items: NavItem[], q: string): NavItem[] {
if (!q) return items;
const lower = q.toLocaleLowerCase("tr-TR");
return items.filter((i) => i.title.toLocaleLowerCase("tr-TR").includes(lower));
}
export function CommandSearch({ open, onOpenChange }: CommandSearchProps) {
const router = useRouter();
export function CommandSearch() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState<SearchResults | null>(null);
const [loading, setLoading] = React.useState(false);
const router = useRouter();
React.useEffect(() => {
if (!open) {
setQuery("");
setResults(null);
setLoading(false);
}
}, [open]);
React.useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setResults(null);
setLoading(false);
return;
}
setLoading(true);
const t = setTimeout(async () => {
try {
const r = await globalSearchAction(trimmed);
setResults(r);
} catch {
setResults(null);
} finally {
setLoading(false);
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
}, 220);
return () => clearTimeout(t);
}, [query]);
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleSelect = (url: string) => {
router.push(url);
onOpenChange(false);
};
const navMatches = filterNav(NAV, query);
const quickMatches = filterNav(QUICK_ACTIONS, query);
const settingsMatches = filterNav(SETTINGS_NAV, query);
const totalEntityHits = results
? GROUP_ORDER.reduce((s, g) => s + results[g].length, 0)
: 0;
const showEmpty =
query.trim().length >= 2 &&
!loading &&
totalEntityHits === 0 &&
navMatches.length === 0 &&
quickMatches.length === 0 &&
settingsMatches.length === 0;
const filtered = query
? NAV_ITEMS.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()))
: NAV_ITEMS;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[640px] overflow-hidden border p-0 shadow-2xl">
<DialogTitle className="sr-only">Arama</DialogTitle>
<Command>
<CommandInput
placeholder="Arayın: müşteri, fatura, görev, etkinlik, hizmet..."
value={query}
onValueChange={setQuery}
autoFocus
/>
<CommandList>
{loading && (
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
<Loader2 className="mr-2 size-3.5 animate-spin" />
Aranıyor...
</div>
)}
<>
<button
onClick={() => setOpen(true)}
className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-sm"
>
<Search className="size-3.5" />
<span>Ara...</span>
<kbd className="bg-background rounded border px-1 text-xs">K</kbd>
</button>
{showEmpty && <CommandEmpty>Sonuç bulunamadı.</CommandEmpty>}
{results &&
!loading &&
GROUP_ORDER.map((group) => {
const hits = results[group];
if (hits.length === 0) return null;
const Icon = GROUP_ICON[group];
return (
<CommandGroup key={group} heading={GROUP_LABEL[group]}>
{hits.map((h: SearchHit) => (
<CommandItem
key={`${group}-${h.id}`}
value={`${group}-${h.id}-${h.title}`}
onSelect={() => handleSelect(h.url)}
>
<Icon className="text-muted-foreground size-4" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{h.title}</span>
{h.subtitle && (
<span className="text-muted-foreground truncate text-xs">
{h.subtitle}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
);
})}
{navMatches.length > 0 && (
<CommandGroup heading="Sayfalar">
{navMatches.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.key}
value={item.key}
onSelect={() => handleSelect(item.url)}
>
<Icon className="text-muted-foreground size-4" />
{item.title}
</CommandItem>
);
})}
</CommandGroup>
)}
{quickMatches.length > 0 && (
<CommandGroup heading="Hızlı aksiyonlar">
{quickMatches.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.key}
value={item.key}
onSelect={() => handleSelect(item.url)}
>
<Icon className="text-muted-foreground size-4" />
{item.title}
</CommandItem>
);
})}
</CommandGroup>
)}
{settingsMatches.length > 0 && (
<CommandGroup heading="Ayarlar">
{settingsMatches.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.key}
value={item.key}
onSelect={() => handleSelect(item.url)}
>
<Icon className="text-muted-foreground size-4" />
{item.title}
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
<div className="text-muted-foreground bg-muted/30 flex items-center justify-between gap-2 border-t px-4 py-2 text-[11px]">
<span> &nbsp;·&nbsp; Gez &nbsp;·&nbsp; Esc Kapat</span>
<kbd className="bg-background rounded border px-1.5 py-0.5 font-mono">K</kbd>
</div>
</Command>
</DialogContent>
</Dialog>
);
}
export function SearchTrigger({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring relative inline-flex h-8 w-full items-center justify-start gap-2 whitespace-nowrap rounded-md border px-3 py-1 text-sm font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 sm:pr-12 md:w-44 lg:w-64"
>
<Search className="mr-1 h-3.5 w-3.5" />
<span>Hızlı ara...</span>
<kbd className="bg-muted pointer-events-none absolute right-1.5 top-1.5 hidden h-4 select-none items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="overflow-hidden p-0">
<DialogTitle className="sr-only">Arama</DialogTitle>
<Command>
<div className="flex items-center border-b px-3">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sayfa ara..."
className="placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none"
/>
</div>
<div className="max-h-72 overflow-y-auto p-2">
{filtered.map((item) => (
<button
key={item.url}
onClick={() => { router.push(item.url); setOpen(false); setQuery(""); }}
className="hover:bg-accent flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm"
>
<item.icon className="size-4 opacity-60" />
{item.title}
</button>
))}
</div>
</Command>
</DialogContent>
</Dialog>
</>
);
}
+21 -38
View File
@@ -3,7 +3,7 @@
import * as React from "react";
import { Building2 } from "lucide-react";
import { CommandSearch, SearchTrigger } from "@/components/command-search";
import { CommandSearch } from "@/components/command-search";
import { ModeToggle } from "@/components/mode-toggle";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
@@ -11,46 +11,29 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
import type { ShellCompany } from "@/app/(dashboard)/dashboard-shell";
export function SiteHeader({ company }: { company?: ShellCompany }) {
const [searchOpen, setSearchOpen] = React.useState(false);
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSearchOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<>
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 py-3 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 py-3 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
{company && (
<div className="text-muted-foreground hidden items-center gap-1.5 text-sm md:flex">
<Building2 className="size-3.5" />
<span className="max-w-[260px] truncate">{company.name}</span>
</div>
)}
<div className="ml-auto flex items-center gap-2">
<div className="hidden w-full max-w-sm md:block">
<SearchTrigger onClick={() => setSearchOpen(true)} />
</div>
<ModeToggle />
{company && (
<div className="text-muted-foreground hidden items-center gap-1.5 text-sm md:flex">
<Building2 className="size-3.5" />
<span className="max-w-[260px] truncate">{company.name}</span>
</div>
)}
<div className="ml-auto flex items-center gap-2">
<div className="hidden w-full max-w-sm md:block">
<CommandSearch />
</div>
<ModeToggle />
</div>
</header>
<CommandSearch open={searchOpen} onOpenChange={setSearchOpen} />
</>
</div>
</header>
);
}