feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap

This commit is contained in:
egecankomur
2026-05-12 04:49:36 +03:00
parent 3cce632eb3
commit 3554b39800
134 changed files with 7736 additions and 1913 deletions
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useActionState, startTransition } from "react";
import { toast } from "sonner";
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createDealAction, updateDealAction } from "@/lib/appwrite/deal-actions";
import type { Customer, Deal } from "@/lib/appwrite/schema";
interface DealFormSheetProps {
open: boolean;
onOpenChange: (v: boolean) => void;
deal?: Deal | null;
customers: Customer[];
onSuccess: () => void;
}
const EMPTY_STATE: { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> } = { ok: false };
export function DealFormSheet({ open, onOpenChange, deal, customers, onSuccess }: DealFormSheetProps) {
const action = deal ? updateDealAction.bind(null, deal.$id) : createDealAction;
const [state, dispatch, isPending] = useActionState(action, EMPTY_STATE);
useEffect(() => {
if (state.ok) {
toast.success(deal ? "İşlem güncellendi." : "İşlem kaydedildi.");
onSuccess();
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => dispatch(fd));
}
const steps = [
{
label: "İşlem",
content: (
<>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Tip</Label>
<Select name="type" defaultValue={deal?.type ?? "satis"} required>
<SelectTrigger><SelectValue placeholder="Seç" /></SelectTrigger>
<SelectContent>
<SelectItem value="satis">Satış</SelectItem>
<SelectItem value="kiralama">Kiralama</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>İlan Başlığı</Label>
<Input name="propertyTitle" placeholder="İsteğe bağlı" defaultValue={deal?.propertyTitle ?? ""} />
</div>
</div>
<div className="grid gap-1.5">
<Label>Müşteri</Label>
<select
name="customerId"
defaultValue={deal?.customerId ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
>
<option value=""> Seçiniz (opsiyonel)</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>{c.name}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label>Satış / Kira Bedeli ()</Label>
<Input name="salePrice" type="number" min={0} step="any" required placeholder="0"
defaultValue={deal?.salePrice ?? ""} />
{state.fieldErrors?.salePrice && (
<p className="text-destructive text-xs">{state.fieldErrors.salePrice[0]}</p>
)}
</div>
</>
),
},
{
label: "Komisyon",
content: (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Oran (%)</Label>
<Input name="commissionRate" type="number" min={0} max={100} step="any" required placeholder="3"
defaultValue={deal?.commissionRate ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Tutar ()</Label>
<Input name="commissionAmount" type="number" min={0} step="any" required placeholder="0"
defaultValue={deal?.commissionAmount ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Ofis payı (%)</Label>
<Input name="officeSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
defaultValue={deal?.officeSharePercent ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Danışman payı (%)</Label>
<Input name="agentSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
defaultValue={deal?.agentSharePercent ?? ""} />
</div>
</div>
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Referans / Ortak Danışman</p>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5 col-span-2">
<Label>Ad Soyad</Label>
<Input name="referralName" placeholder="Ahmet Yılmaz (opsiyonel)"
defaultValue={deal?.referralName ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Telefon</Label>
<Input name="referralPhone" placeholder="05xx xxx xx xx"
defaultValue={deal?.referralPhone ?? ""} />
</div>
<div className="grid gap-1.5">
<Label>Komisyondan pay (%)</Label>
<Input name="referralPercent" type="number" min={0} max={100} step="any"
placeholder="örn. 25"
defaultValue={deal?.referralPercent ?? ""} />
</div>
</div>
</div>
</div>
),
},
{
label: "Kapanış",
content: (
<>
<div className="grid gap-1.5">
<Label>Kapanış Tarihi</Label>
<Input name="closingDate" type="date"
defaultValue={deal?.closingDate ? deal.closingDate.slice(0, 10) : ""} />
</div>
<div className="grid gap-1.5">
<Label>Notlar</Label>
<Textarea name="notes" rows={4} placeholder="Opsiyonel notlar..." defaultValue={deal?.notes ?? ""} />
</div>
</>
),
},
];
return (
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
title={deal ? "İşlemi Düzenle" : "Yeni İşlem"}
maxWidth="sm:max-w-lg">
<form onSubmit={handleSubmit}>
<FormWizard steps={steps} isPending={isPending} submitLabel={deal ? "Güncelle" : "Kaydet"} />
</form>
</ResponsiveSheet>
);
}
+459
View File
@@ -0,0 +1,459 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, XCircle, TrendUp, Wallet, Users, Clock, UserCheck } from '@/lib/icons';
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { DealFormSheet } from "./deal-form-sheet";
import {
updateDealStatusAction,
deleteDealAction,
} from "@/lib/appwrite/deal-actions";
import type { Deal, DealStatus, Customer } from "@/lib/appwrite/schema";
import {
DEAL_TYPE_LABELS,
DEAL_STATUS_LABELS,
} from "@/lib/appwrite/schema";
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
interface FinanceClientProps {
initialDeals: Deal[];
role: TenantRole;
userId: string;
userName: string;
customers: Customer[];
}
const STATUS_FILTER_OPTIONS = [
["all", "Tümü"],
["bekleyen", "Bekleyen"],
["tahsil_edildi", "Tahsil Edildi"],
["iptal", "İptal"],
] as const;
function fmt(n: number) {
return n.toLocaleString("tr-TR", { maximumFractionDigits: 0 });
}
export function FinanceClient({ initialDeals, role, userId, customers }: FinanceClientProps) {
const router = useRouter();
const [deals, setDeals] = useState<Deal[]>(initialDeals);
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Deal | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Deal | null>(null);
const [statusFilter, setStatusFilter] = useState<DealStatus | "all">("all");
const isOwnerOrAdmin = role === "owner" || role === "admin";
const filtered = useMemo(() => {
if (statusFilter === "all") return deals;
return deals.filter((d) => (d.status ?? "bekleyen") === statusFilter);
}, [deals, statusFilter]);
// ---- Stats ----
const stats = useMemo(() => {
const collected = deals.filter((d) => d.status === "tahsil_edildi");
const pending = deals.filter((d) => (d.status ?? "bekleyen") === "bekleyen");
const totalCommission = collected.reduce((s, d) => s + d.commissionAmount, 0);
const pendingCommission = pending.reduce((s, d) => s + d.commissionAmount, 0);
const agentEarnings = collected.reduce(
(s, d) =>
s + (d.agentSharePercent != null
? (d.commissionAmount * d.agentSharePercent) / 100
: d.commissionAmount),
0,
);
const officeEarnings = collected.reduce(
(s, d) =>
s + (d.officeSharePercent != null
? (d.commissionAmount * d.officeSharePercent) / 100
: 0),
0,
);
const referralPaid = collected.reduce(
(s, d) =>
s + (d.referralPercent != null
? (d.commissionAmount * d.referralPercent) / 100
: 0),
0,
);
// Group by agent for leaderboard (owner/admin only)
const byAgent: Record<string, { name: string; count: number; earnings: number }> = {};
collected.forEach((d) => {
if (!byAgent[d.agentId]) byAgent[d.agentId] = { name: d.agentName, count: 0, earnings: 0 };
byAgent[d.agentId].count++;
byAgent[d.agentId].earnings +=
d.agentSharePercent != null
? (d.commissionAmount * d.agentSharePercent) / 100
: d.commissionAmount;
});
const leaderboard = Object.entries(byAgent)
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => b.earnings - a.earnings);
return { totalCommission, pendingCommission, agentEarnings, officeEarnings, referralPaid, leaderboard, pendingCount: pending.length };
}, [deals]);
useEffect(() => {
const open = () => { setEditing(null); setSheetOpen(true); };
const close = () => setSheetOpen(false);
window.addEventListener("kovak:open-form-finance", open);
window.addEventListener("kovak:close-form-finance", close);
return () => {
window.removeEventListener("kovak:open-form-finance", open);
window.removeEventListener("kovak:close-form-finance", close);
};
}, []);
function openCreate() { setEditing(null); setSheetOpen(true); }
function openEdit(d: Deal) { setEditing(d); setSheetOpen(true); }
async function handleStatusChange(deal: Deal, status: DealStatus) {
setDeals((prev) =>
prev.map((d) => (d.$id === deal.$id ? { ...d, status } : d)),
);
const result = await updateDealStatusAction(deal.$id, status);
if (!result.ok) {
setDeals((prev) =>
prev.map((d) => (d.$id === deal.$id ? deal : d)),
);
toast.error(result.error ?? "Durum güncellenemedi.");
}
}
async function doDelete() {
if (!deleteTarget) return;
const result = await deleteDealAction(deleteTarget.$id);
if (result.ok) {
setDeals((prev) => prev.filter((d) => d.$id !== deleteTarget.$id));
setDeleteTarget(null);
toast.success("İşlem silindi.");
} else {
toast.error(result.error ?? "Silinemedi.");
}
}
return (
<div className="flex flex-col gap-6 flex-1">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Finans</h1>
<Button onClick={openCreate} size="sm" data-tour="finance-add">
<Plus className="mr-1.5 size-4" />
Yeni İşlem
</Button>
</div>
{/* Stats cards */}
{isOwnerOrAdmin ? (
<div data-tour="finance-stats" className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-5">
<StatCard
icon={TrendUp}
label="Toplam Komisyon"
value={`${fmt(stats.totalCommission)}`}
sub="tahsil edilen"
color="text-emerald-600"
/>
<StatCard
icon={Clock}
label="Bekleyen Komisyon"
value={`${fmt(stats.pendingCommission)}`}
sub={`${stats.pendingCount} işlem`}
color="text-amber-600"
/>
<StatCard
icon={Wallet}
label="Ofis Geliri"
value={`${fmt(stats.officeEarnings)}`}
sub="tahsil edilen paydan"
color="text-blue-600"
/>
<StatCard
icon={Users}
label="Danışman Geliri"
value={`${fmt(stats.agentEarnings)}`}
sub="tahsil edilen paydan"
color="text-purple-600"
/>
{stats.referralPaid > 0 && (
<StatCard
icon={UserCheck}
label="Referans Ödemesi"
value={`${fmt(stats.referralPaid)}`}
sub="tahsil edilenden"
color="text-rose-600"
/>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<StatCard
icon={TrendUp}
label="Kazancım"
value={`${fmt(stats.agentEarnings)}`}
sub="tahsil edilen"
color="text-emerald-600"
/>
<StatCard
icon={Clock}
label="Bekleyen"
value={`${fmt(stats.pendingCommission)}`}
sub={`${stats.pendingCount} işlem`}
color="text-amber-600"
/>
<StatCard
icon={Wallet}
label="Toplam İşlem"
value={String(deals.length)}
sub="kayıtlı"
color="text-blue-600"
/>
</div>
)}
{/* Leaderboard (owner/admin only) */}
{isOwnerOrAdmin && stats.leaderboard.length > 0 && (
<div className="rounded-lg border p-4">
<p className="text-sm font-semibold mb-3">Danışman Performansı</p>
<div className="flex flex-col gap-2">
{stats.leaderboard.map((agent, i) => (
<div key={agent.id} className="flex items-center gap-3">
<span className="text-muted-foreground text-xs w-4">{i + 1}.</span>
<span className="text-sm flex-1 truncate">{agent.name}</span>
<span className="text-xs text-muted-foreground">{agent.count} işlem</span>
<span className="text-sm font-medium tabular-nums">{fmt(agent.earnings)}</span>
</div>
))}
</div>
</div>
)}
{/* Status filter */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTER_OPTIONS.map(([key, label]) => {
const count = key === "all" ? deals.length : deals.filter((d) => (d.status ?? "bekleyen") === key).length;
if (key !== "all" && count === 0) return null;
return (
<button
key={key}
type="button"
onClick={() => setStatusFilter(key as DealStatus | "all")}
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
statusFilter === key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
})}
</div>
{/* Table */}
<div data-tour="finance-table" className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>İlan / Müşteri</TableHead>
<TableHead>Tip</TableHead>
{isOwnerOrAdmin && <TableHead>Danışman</TableHead>}
<TableHead>Satış Bedeli</TableHead>
<TableHead>Komisyon</TableHead>
<TableHead>Danışman Payı</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Tarih</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell
colSpan={isOwnerOrAdmin ? 9 : 8}
className="text-muted-foreground text-center py-10"
>
{deals.length === 0 ? "Henüz işlem yok." : "Filtreye uyan işlem yok."}
</TableCell>
</TableRow>
)}
{filtered.map((deal) => {
const agentEarning =
deal.agentSharePercent != null
? (deal.commissionAmount * deal.agentSharePercent) / 100
: deal.commissionAmount;
const canEdit = isOwnerOrAdmin || deal.agentId === userId;
return (
<TableRow key={deal.$id} className={deal.status === "iptal" ? "opacity-50" : ""}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-sm truncate max-w-[180px]">
{deal.propertyTitle ?? "—"}
</span>
{deal.customerName && (
<span className="text-muted-foreground text-xs">{deal.customerName}</span>
)}
{deal.referralName && (
<span className="text-xs text-rose-600 flex items-center gap-0.5 mt-0.5">
<UserCheck className="size-3 shrink-0" />
{deal.referralName}
{deal.referralPercent != null && ` (%${deal.referralPercent})`}
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{DEAL_TYPE_LABELS[deal.type]}
</Badge>
</TableCell>
{isOwnerOrAdmin && (
<TableCell className="text-sm text-muted-foreground">
{deal.agentName}
</TableCell>
)}
<TableCell className="tabular-nums text-sm">
{fmt(deal.salePrice)}
</TableCell>
<TableCell className="tabular-nums text-sm">
{fmt(deal.commissionAmount)}
<span className="text-muted-foreground text-xs ml-1">
(%{deal.commissionRate})
</span>
</TableCell>
<TableCell className="tabular-nums text-sm font-medium">
{fmt(agentEarning)}
{deal.agentSharePercent != null && (
<span className="text-muted-foreground text-xs ml-1">
(%{deal.agentSharePercent})
</span>
)}
</TableCell>
<TableCell>
<DealStatusBadge status={deal.status} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{deal.closingDate
? new Date(deal.closingDate).toLocaleDateString("tr-TR")
: "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<DotsThree className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={() => openEdit(deal)}>
<PencilSimple className="mr-2 size-4" />
Düzenle
</DropdownMenuItem>
)}
{isOwnerOrAdmin && deal.status !== "tahsil_edildi" && (
<DropdownMenuItem onClick={() => handleStatusChange(deal, "tahsil_edildi")}>
<CheckCircle className="mr-2 size-4 text-emerald-600" />
Tahsil Edildi
</DropdownMenuItem>
)}
{isOwnerOrAdmin && deal.status !== "iptal" && (
<DropdownMenuItem onClick={() => handleStatusChange(deal, "iptal")}>
<XCircle className="mr-2 size-4 text-destructive" />
İptal Et
</DropdownMenuItem>
)}
{isOwnerOrAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteTarget(deal)}
className="text-destructive focus:text-destructive"
>
<Trash className="mr-2 size-4" />
Sil
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DealFormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
deal={editing}
customers={customers}
onSuccess={() => router.refresh()}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
title={`Bu işlem silinsin mi?`}
description="İşlem kalıcı olarak silinecek ve geri alınamaz."
onConfirm={doDelete}
/>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
sub,
color,
}: {
icon: React.ElementType;
label: string;
value: string;
sub: string;
color: string;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<Icon className={`size-4 ${color}`} />
<span className="text-muted-foreground text-xs">{label}</span>
</div>
<p className="text-xl font-bold tabular-nums">{value}</p>
<p className="text-muted-foreground text-xs">{sub}</p>
</div>
);
}
function DealStatusBadge({ status }: { status?: string | null }) {
const map: Record<string, string> = {
bekleyen: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
tahsil_edildi: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
iptal: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
};
const key = status ?? "bekleyen";
const label = DEAL_STATUS_LABELS[key as keyof typeof DEAL_STATUS_LABELS] ?? key;
return (
<span className={`inline-flex text-xs font-medium px-2 py-0.5 rounded-full ${map[key] ?? map.bekleyen}`}>
{label}
</span>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Building2, User } from "lucide-react";
import { Buildings, User } from '@/lib/icons';
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -36,7 +36,7 @@ export function ScopeToggle({
)}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="size-4" />
<Buildings className="size-4" />
Şirket
</div>
<p className="text-muted-foreground text-xs">