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
@@ -1,372 +0,0 @@
import Link from "next/link";
import {
ArrowUpRight,
CheckCircle2,
CreditCard,
Crown,
Lock,
ShieldCheck,
Sparkles,
Star,
Trash2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
removeCardAction,
setDefaultCardAction,
} from "@/lib/appwrite/saved-card-actions";
import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
import type { CardBrand } from "@/lib/appwrite/schema";
import { listPaymentsForTenant } from "@/lib/appwrite/subscription-queries";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import { getEffectivePlan } from "@/lib/appwrite/plan-limits";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
const trFmt = new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
maximumFractionDigits: 0,
});
const dateFmt = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
});
const STATUS_LABEL: Record<string, string> = {
pending: "Bekliyor",
success: "Başarılı",
failed: "İptal",
refunded: "İade",
};
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
pending: "secondary",
success: "default",
failed: "outline",
refunded: "destructive",
};
const PROVIDER_LABEL: Record<string, string> = {
mock: "Mock (Test)",
shopier: "Shopier",
};
const BRAND_LABEL: Record<CardBrand, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "Amex",
troy: "troy",
unknown: "Kart",
};
export default async function BillingSettings({
searchParams,
}: {
searchParams: Promise<{ upgraded?: string; cancelled?: string; downgraded?: string }>;
}) {
const sp = await searchParams;
const ctx = await requireTenant();
const plan = getEffectivePlan(ctx);
const isPro = plan === "pro";
const canManage = ctx.role === "owner";
const [payments, savedCards] = await Promise.all([
listPaymentsForTenant(ctx.tenantId, 10),
listSavedCards(ctx.tenantId),
]);
const expiresAt = ctx.settings?.planExpiresAt;
const catalog = isPro ? PLAN_CATALOG.pro : PLAN_CATALOG.free;
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">
{ctx.settings?.companyName ?? "Çalışma alanı"}
</p>
<h1 className="text-2xl font-bold tracking-tight">Faturalandırma</h1>
<p className="text-muted-foreground text-sm">
Kayıtlı ödeme yöntemlerini, faturalarını ve veri saklama bilgilerini buradan yönet.
</p>
</div>
{sp.upgraded && (
<Card className="border-emerald-500/40 bg-emerald-500/5">
<CardContent className="flex items-center gap-3 py-4 text-sm">
<CheckCircle2 className="size-5 text-emerald-600" />
<span>Pro plan aktif. Sınırsız kullanım açıldı.</span>
</CardContent>
</Card>
)}
{sp.cancelled && (
<Card className="border-amber-500/40 bg-amber-500/5">
<CardContent className="py-4 text-sm">
Ödeme iptal edildi. Plan değişmedi.
</CardContent>
</Card>
)}
{sp.downgraded && (
<Card className="border-amber-500/40 bg-amber-500/5">
<CardContent className="py-4 text-sm">Ücretsiz plana döndünüz.</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
{isPro ? (
<Crown className="size-5 text-amber-500" />
) : (
<Sparkles className="size-5 text-muted-foreground" />
)}
{catalog.name} plan
</CardTitle>
<CardDescription>
{isPro && expiresAt
? `Yenileme tarihi: ${dateFmt.format(new Date(expiresAt))}`
: isPro
? "Sınırsız kullanım."
: "Ücretsiz plan — sınırlar dolduğunda Pro'ya geç."}
</CardDescription>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/pricing">
Planları gör
<ArrowUpRight className="size-3.5" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trFmt.format(catalog.price)}
<span className="text-muted-foreground text-sm font-normal"> /ay</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Kayıtlı kartlar</CardTitle>
<CardDescription>
Sonraki ödemelerde kullanılacak ödeme yöntemleri.
</CardDescription>
</div>
{canManage && !isPro && (
<Button asChild variant="outline" size="sm">
<Link href="/pricing">
Pro'ya geç
<ArrowUpRight className="size-3.5" />
</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
{savedCards.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-8 text-center text-sm">
<CreditCard className="size-8 opacity-40" />
<p>Henüz kayıtlı kart yok.</p>
<p className="text-xs">
Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya
eklenir.
</p>
</div>
) : (
<ul className="divide-y">
{savedCards.map((c) => (
<li
key={c.$id}
className={cn(
"flex items-center gap-4 py-3",
c.isDefault && "bg-primary/5 -mx-2 rounded-md px-2",
)}
>
<div className="bg-muted flex size-10 shrink-0 items-center justify-center rounded-md">
<CreditCard className="size-5" />
</div>
<div className="flex-1 text-sm">
<div className="flex items-center gap-2 font-medium">
{BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
{c.isDefault && (
<Badge variant="secondary" className="gap-1 text-[10px]">
<Star className="!size-3" />
Varsayılan
</Badge>
)}
{c.provider === "mock" && (
<Badge variant="outline" className="text-[10px]">
Mock
</Badge>
)}
</div>
<div className="text-muted-foreground text-xs">
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
</div>
</div>
{canManage && (
<div className="flex gap-1">
{!c.isDefault && (
<form action={setDefaultCardAction}>
<input type="hidden" name="id" value={c.$id} />
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs"
>
Varsayılan yap
</Button>
</form>
)}
<form action={removeCardAction}>
<input type="hidden" name="id" value={c.$id} />
<Button
type="submit"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
Sil
</Button>
</form>
</div>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card className="bg-muted/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="size-4 text-emerald-600" />
Kart bilgileriniz nasıl saklanır?
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<Lock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Ham kart numarası saklanmaz</div>
<p className="text-muted-foreground text-xs">
İşletmem sunucularında kart numaranızın yalnızca son 4 hanesi, markası, son
kullanma tarihi ve kart sahibi adı tutulur. Tam kart numarası ve CVC asla
kaydedilmez.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<ShieldCheck className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Mock test modu</div>
<p className="text-muted-foreground text-xs">
Şu an Pro plan mock ödeme akışıyla çalışır — gerçek tahsilat yapılmaz. Test
amaçlı girilen kart numaralarına ait yalnızca son 4 hane görüntü amacıyla
kaydedilir.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CreditCard className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Shopier entegrasyonu sonrası</div>
<p className="text-muted-foreground text-xs">
Shopier (Türkiye'de PCI-DSS uyumlu, BDDK lisanslı ödeme hizmeti sağlayıcısı)
devreye girdiğinde kart bilgileri Shopier'in altyapısında tokenize edilir.
İşletmem yalnızca tokeni saklar; bir sonraki ödemede tokenle Shopier'e tekrar
başvurulur. Token yalnızca o aboneliğe özeldir.
</p>
</div>
</div>
<div className="text-muted-foreground border-t pt-3 text-xs">
KVKK Aydınlatma Metni ve Mesafeli Satış Sözleşmesi yakında bu sayfaya eklenecek.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ödeme geçmişi</CardTitle>
<CardDescription>Son 10 işlem.</CardDescription>
</CardHeader>
<CardContent>
{payments.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
Henüz ödeme kaydı yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Sipariş No</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Sağlayıcı</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">Tutar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.$id}>
<TableCell className="text-muted-foreground">
{dateFmt.format(new Date(p.$createdAt))}
</TableCell>
<TableCell className="font-mono text-xs">{p.orderId}</TableCell>
<TableCell className="capitalize">{p.plan}</TableCell>
<TableCell>{PROVIDER_LABEL[p.provider ?? "mock"]}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANT[p.status ?? "pending"]}>
{STATUS_LABEL[p.status ?? "pending"]}
</Badge>
{p.status === "pending" && (
<Link
href={`/settings/billing/checkout/${p.orderId}`}
className="text-primary ml-2 inline-flex items-center gap-0.5 text-xs hover:underline"
>
Devam <ArrowUpRight className="size-3" />
</Link>
)}
</TableCell>
<TableCell className="text-right font-mono">
{trFmt.format(p.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}