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
+9 -38
View File
@@ -1,64 +1,35 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"],
},
serverActions: {
bodySizeLimit: "3mb",
},
turbopack: {}, turbopack: {},
// TODO: re-enable once template files (chart.tsx, data-table-toolbar.tsx) are cleaned up.
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
// Image optimization
images: { images: {
remotePatterns: [ remotePatterns: [
{ { protocol: "https", hostname: "ui.shadcn.com" },
protocol: 'https', { protocol: "https", hostname: "images.unsplash.com" },
hostname: 'ui.shadcn.com', { protocol: "https", hostname: "db.kovaksoft.com" },
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
], ],
formats: ['image/webp', 'image/avif'], formats: ["image/webp", "image/avif"],
}, },
// Headers for better security and performance
async headers() { async headers() {
return [ return [
{ {
source: '/(.*)', source: "/(.*)",
headers: [ headers: [
{ { key: "X-Frame-Options", value: "DENY" },
key: 'X-Frame-Options', { key: "X-Content-Type-Options", value: "nosniff" },
value: 'DENY', { key: "Referrer-Policy", value: "origin-when-cross-origin" },
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
], ],
}, },
]; ];
}, },
// Redirects for better SEO
async redirects() { async redirects() {
return [ return [
{ { source: "/home", destination: "/dashboard", permanent: true },
source: '/home',
destination: '/dashboard',
permanent: true,
},
]; ];
}, },
}; };
+8
View File
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold capitalize">activities</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
@@ -1,259 +0,0 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteCalendarEventAction } from "@/lib/appwrite/calendar-actions";
import { cn } from "@/lib/utils";
import { EventFormSheet } from "./event-form-sheet";
import { COLOR_BG, type Customer, type EventRow } from "./types";
type Props = {
events: EventRow[];
customers: Customer[];
};
const WEEKDAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
const MONTH_NAMES = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
];
function startOfMonthGrid(year: number, month: number): Date {
// Monday-first grid; first cell is the Monday on/before the 1st
const first = new Date(year, month, 1);
const dayIdx = (first.getDay() + 6) % 7; // 0 = Mon
return new Date(year, month, 1 - dayIdx);
}
function ymd(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function CalendarClient({ events, customers }: Props) {
const today = new Date();
const [cursor, setCursor] = useState(new Date(today.getFullYear(), today.getMonth(), 1));
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<EventRow | null>(null);
const [defaultDate, setDefaultDate] = useState<string | undefined>();
const [deleting, setDeleting] = useState<EventRow | null>(null);
const [busy, startTransition] = useTransition();
const eventsByDay = useMemo(() => {
const map = new Map<string, EventRow[]>();
for (const e of events) {
const start = new Date(e.start);
const end = new Date(e.end);
const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
while (cur.getTime() <= last.getTime()) {
const key = ymd(cur);
const arr = map.get(key) ?? [];
arr.push(e);
map.set(key, arr);
cur.setDate(cur.getDate() + 1);
}
}
return map;
}, [events]);
const grid = useMemo(() => {
const start = startOfMonthGrid(cursor.getFullYear(), cursor.getMonth());
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return days;
}, [cursor]);
const handlePrev = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
const handleNext = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));
const handleToday = () => setCursor(new Date(today.getFullYear(), today.getMonth(), 1));
const handleAddOnDay = (date: Date) => {
setEditing(null);
setDefaultDate(ymd(date));
setFormOpen(true);
};
const handleAddNew = () => {
setEditing(null);
setDefaultDate(ymd(today));
setFormOpen(true);
};
const handleEdit = (event: EventRow) => {
setEditing(event);
setDefaultDate(undefined);
setFormOpen(true);
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteCalendarEventAction(fd);
if (result.ok) {
toast.success("Etkinlik silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
const todayKey = ymd(today);
return (
<Card>
<CardContent className="p-4">
<div className="mb-4 flex flex-col items-center justify-between gap-3 md:flex-row">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-8" onClick={handlePrev}>
<ChevronLeft className="size-4" />
</Button>
<h2 className="text-lg font-semibold">
{MONTH_NAMES[cursor.getMonth()]} {cursor.getFullYear()}
</h2>
<Button variant="outline" size="icon" className="size-8" onClick={handleNext}>
<ChevronRight className="size-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleToday}>
Bugün
</Button>
</div>
<Button onClick={handleAddNew}>
<Plus className="size-4" />
Yeni etkinlik
</Button>
</div>
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-md border bg-border">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="bg-muted/40 text-muted-foreground py-2 text-center text-xs font-medium"
>
{wd}
</div>
))}
{grid.map((d) => {
const inMonth = d.getMonth() === cursor.getMonth();
const key = ymd(d);
const isToday = key === todayKey;
const dayEvents = eventsByDay.get(key) ?? [];
return (
<div
key={key}
className={cn(
"bg-card group relative flex min-h-[110px] flex-col gap-1 p-1.5",
!inMonth && "bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"inline-flex size-6 items-center justify-center rounded-full text-xs",
isToday && "bg-primary text-primary-foreground font-medium",
!inMonth && "text-muted-foreground",
)}
>
{d.getDate()}
</span>
<button
type="button"
onClick={() => handleAddOnDay(d)}
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100"
aria-label="Bu güne etkinlik ekle"
>
<Plus className="size-3.5" />
</button>
</div>
<div className="flex flex-col gap-0.5">
{dayEvents.slice(0, 3).map((e) => (
<button
key={e.id}
type="button"
onClick={() => handleEdit(e)}
className={cn(
"truncate rounded border px-1.5 py-0.5 text-left text-xs",
COLOR_BG[e.color] ?? COLOR_BG[""],
)}
title={e.title}
>
{!e.allDay && (
<span className="opacity-70">
{new Date(e.start).toLocaleTimeString("tr-TR", {
hour: "2-digit",
minute: "2-digit",
})}{" "}
</span>
)}
{e.title}
</button>
))}
{dayEvents.length > 3 && (
<span className="text-muted-foreground px-1 text-xs">
+{dayEvents.length - 3} daha
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
<EventFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
event={editing}
defaultDate={defaultDate}
customers={customers}
onRequestDelete={(e) => {
setFormOpen(false);
setDeleting(e);
}}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Etkinliği sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -1,274 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
createCalendarEventAction,
updateCalendarEventAction,
} from "@/lib/appwrite/calendar-actions";
import { initialCalendarState } from "@/lib/appwrite/calendar-types";
import { cn } from "@/lib/utils";
import { COLOR_PRESETS, type Customer, type EventRow } from "./types";
const NONE = "__none__";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
event?: EventRow | null;
defaultDate?: string; // YYYY-MM-DD for new events
customers: Customer[];
onRequestDelete?: (event: EventRow) => void;
};
function isoToInput(iso: string, allDay: boolean): string {
if (!iso) return "";
if (allDay) return iso.slice(0, 10);
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function EventFormSheet({
open,
onOpenChange,
event,
defaultDate,
customers,
onRequestDelete,
}: Props) {
const isEdit = Boolean(event);
const action = isEdit ? updateCalendarEventAction : createCalendarEventAction;
const [state, formAction, isPending] = useActionState(action, initialCalendarState);
const [allDay, setAllDay] = useState<boolean>(event?.allDay ?? false);
useEffect(() => {
setAllDay(event?.allDay ?? false);
}, [event]);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Etkinlik güncellendi." : "Etkinlik eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const startDefault =
event?.start
? isoToInput(event.start, allDay)
: defaultDate
? allDay
? defaultDate
: `${defaultDate}T09:00`
: "";
const endDefault =
event?.end
? isoToInput(event.end, allDay)
: defaultDate
? allDay
? defaultDate
: `${defaultDate}T10:00`
: "";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Etkinliği düzenle" : "Yeni etkinlik"}</SheetTitle>
<SheetDescription>
Tarih, saat ve müşteri bilgileri ile bir takvim girdisi oluşturun.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
["customerId", "color"].forEach((k) => {
if (fd.get(k) === NONE) fd.set(k, "");
});
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && event && <input type="hidden" name="id" value={event.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="title">Başlık *</Label>
<Input
id="title"
name="title"
defaultValue={event?.title ?? ""}
placeholder="Örn. Müşteri toplantısı"
required
/>
{state.fieldErrors?.title && (
<p className="text-destructive text-xs">{state.fieldErrors.title}</p>
)}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="grid gap-0.5">
<Label htmlFor="allDay" className="cursor-pointer">
Tüm gün
</Label>
<p className="text-muted-foreground text-xs">Saat girmeden gün boyu sürecek.</p>
</div>
<Switch
id="allDay"
name="allDay"
checked={allDay}
onCheckedChange={setAllDay}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="start">Başlangıç *</Label>
<Input
id="start"
name="start"
type={allDay ? "date" : "datetime-local"}
defaultValue={startDefault}
required
/>
{state.fieldErrors?.start && (
<p className="text-destructive text-xs">{state.fieldErrors.start}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="end">Bitiş *</Label>
<Input
id="end"
name="end"
type={allDay ? "date" : "datetime-local"}
defaultValue={endDefault}
required
/>
{state.fieldErrors?.end && (
<p className="text-destructive text-xs">{state.fieldErrors.end}</p>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
<Select name="customerId" defaultValue={event?.customerId || NONE}>
<SelectTrigger id="customerId">
<SelectValue placeholder="Yok" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="color">Renk</Label>
<Select name="color" defaultValue={event?.color || NONE}>
<SelectTrigger id="color">
<SelectValue placeholder="Varsayılan" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Varsayılan</SelectItem>
{COLOR_PRESETS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<span className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", c.classes)} />
{c.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Notlar</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={event?.description ?? ""}
placeholder="Açıklama, gündem, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2">
<div>
{isEdit && event && onRequestDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRequestDelete(event)}
disabled={isPending}
>
<Trash2 className="size-3.5" />
Sil
</Button>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,32 +0,0 @@
export type EventRow = {
id: string;
title: string;
description: string;
start: string;
end: string;
allDay: boolean;
customerId: string;
customerName: string;
color: string;
};
export type Customer = { id: string; name: string };
export const COLOR_PRESETS = [
{ value: "blue", label: "Mavi", classes: "bg-blue-500" },
{ value: "green", label: "Yeşil", classes: "bg-emerald-500" },
{ value: "amber", label: "Amber", classes: "bg-amber-500" },
{ value: "red", label: "Kırmızı", classes: "bg-red-500" },
{ value: "violet", label: "Mor", classes: "bg-violet-500" },
{ value: "slate", label: "Gri", classes: "bg-slate-500" },
] as const;
export const COLOR_BG: Record<string, string> = {
blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
green: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
amber: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
red: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
violet: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30",
slate: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
"": "bg-primary/10 text-primary border-primary/20",
};
-54
View File
@@ -1,54 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCalendarEvents } from "@/lib/appwrite/calendar-queries";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CalendarClient } from "./components/calendar-client";
export const metadata: Metadata = {
title: "İşletmem — Takvim",
};
export default async function CalendarPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [events, customers] = await Promise.all([
listCalendarEvents(ctx.tenantId),
listCustomers(ctx.tenantId),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
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">Takvim</h1>
<p className="text-muted-foreground text-sm">
Toplantılar, randevular ve önemli tarihler.
</p>
</div>
<CalendarClient
events={events.map((e) => ({
id: e.$id,
title: e.title,
description: e.description ?? "",
start: e.start,
end: e.end,
allDay: Boolean(e.allDay),
customerId: e.customerId ?? "",
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
color: e.color ?? "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
);
}
@@ -1,233 +0,0 @@
"use client"
import {
Phone,
Video,
Info,
Search,
MoreVertical,
Users,
Bell,
BellOff
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
import { type Conversation, type User } from "../use-chat"
interface ChatHeaderProps {
conversation: Conversation | null
users: User[]
onToggleMute?: () => void
onToggleInfo?: () => void
}
export function ChatHeader({
conversation,
users,
onToggleMute,
onToggleInfo
}: ChatHeaderProps) {
if (!conversation) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Select a conversation to start chatting</p>
</div>
)
}
const getConversationUsers = () => {
if (conversation.type === "direct") {
return users.filter(user => conversation.participants.includes(user.id))
}
return users.filter(user => conversation.participants.includes(user.id))
}
const conversationUsers = getConversationUsers()
const primaryUser = conversationUsers[0]
const getStatusText = () => {
if (conversation.type === "group") {
const onlineCount = conversationUsers.filter(user => user.status === "online").length
return `${conversation.participants.length} members, ${onlineCount} online`
} else if (primaryUser) {
switch (primaryUser.status) {
case "online":
return "Active now"
case "away":
return "Away"
case "offline":
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
default:
return ""
}
}
return ""
}
const getStatusColor = () => {
if (conversation.type === "group") return "text-muted-foreground"
switch (primaryUser?.status) {
case "online":
return "text-green-600"
case "away":
return "text-yellow-600"
case "offline":
return "text-muted-foreground"
default:
return "text-muted-foreground"
}
}
return (
<div className="flex items-center justify-between h-full">
{/* Left side - Avatar and info */}
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 cursor-pointer">
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback>
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="font-semibold truncate">{conversation.name}</h2>
{conversation.isMuted && (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
{conversation.type === "group" && (
<Badge variant="secondary" className="text-xs cursor-pointer">
Group
</Badge>
)}
</div>
<p className={`text-sm ${getStatusColor()}`}>
{getStatusText()}
</p>
</div>
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-1">
<TooltipProvider>
{/* Search */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search in conversation</p>
</TooltipContent>
</Tooltip>
{/* Phone call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Phone className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Voice call</p>
</TooltipContent>
</Tooltip>
{/* Video call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Video className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Video call</p>
</TooltipContent>
</Tooltip>
{/* Info */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onToggleInfo}
className="cursor-pointer"
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Conversation info</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* More options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onToggleMute}
className="cursor-pointer"
>
{conversation.isMuted ? (
<>
<Bell className="h-4 w-4 mr-2" />
Unmute conversation
</>
) : (
<>
<BellOff className="h-4 w-4 mr-2" />
Mute conversation
</>
)}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Search messages
</DropdownMenuItem>
{conversation.type === "group" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Users className="h-4 w-4 mr-2" />
Manage members
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -1,193 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Menu, X } from "lucide-react"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import { ConversationList } from "./conversation-list"
import { ChatHeader } from "./chat-header"
import { MessageList } from "./message-list"
import { MessageInput } from "./message-input"
import { useChat, type Conversation, type Message, type User } from "../use-chat"
interface ChatProps {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
}
export function Chat({
conversations,
messages,
users,
}: ChatProps) {
const {
selectedConversation,
setSelectedConversation,
setConversations,
setMessages,
setUsers,
addMessage,
toggleMute,
} = useChat()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// Close sidebar when clicking outside on mobile
useEffect(() => {
const handleResize = () => {
if (typeof window !== "undefined" ? window.innerWidth : 0 >= 1024) { // lg breakpoint
setIsSidebarOpen(false)
}
}
if (typeof window !== "undefined") {
window.addEventListener('resize', handleResize)
}
return () => {
if (typeof window !== "undefined") {
window.removeEventListener('resize', handleResize)
}
}
}, [])
// Initialize data
useEffect(() => {
setConversations(conversations)
setUsers(users)
// Set messages for all conversations
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
setMessages(conversationId, conversationMessages)
})
// Auto-select first conversation if none selected
if (!selectedConversation && conversations.length > 0) {
setSelectedConversation(conversations[0].id)
}
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
const handleSendMessage = (content: string) => {
if (!selectedConversation) return
const newMessage = {
id: `msg-${Date.now()}`,
content,
timestamp: new Date().toISOString(),
senderId: "current-user",
type: "text" as const,
isEdited: false,
reactions: [],
replyTo: null,
}
addMessage(selectedConversation, newMessage)
}
const handleToggleMute = () => {
if (selectedConversation) {
toggleMute(selectedConversation)
}
}
return (
<TooltipProvider delayDuration={0}>
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Conversations Sidebar - Responsive */}
<div className={`
w-100 border-r bg-background flex-shrink-0
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
lg:relative lg:block
fixed inset-y-0 left-0 z-50
transition-transform duration-300 ease-in-out
`}>
{/* Sidebar Header with Close Button (Mobile Only) */}
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
<h2 className="text-lg font-semibold">Messages</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(false)}
className="cursor-pointer"
>
<X className="h-4 w-4" />
</Button>
</div>
<ConversationList
conversations={conversations}
selectedConversation={selectedConversation}
onSelectConversation={(id) => {
setSelectedConversation(id)
setIsSidebarOpen(false) // Close sidebar on mobile after selection
}}
/>
</div>
{/* Chat Panel - Flexible Width */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{/* Chat Header with Hamburger Menu */}
<div className="flex items-center h-16 px-4 border-b bg-background">
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(true)}
className="cursor-pointer lg:hidden mr-2"
>
<Menu className="h-4 w-4" />
</Button>
<div className="flex-1">
<ChatHeader
conversation={currentConversation || null}
users={users}
onToggleMute={handleToggleMute}
/>
</div>
</div>
{/* Messages */}
<div className="flex-1 flex flex-col min-h-0">
{selectedConversation ? (
<>
<MessageList
messages={currentMessages}
users={users}
/>
{/* Message Input */}
<MessageInput
onSendMessage={handleSendMessage}
placeholder={`Message ${currentConversation?.name || ""}...`}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
<p className="text-muted-foreground">
Select a conversation to start messaging
</p>
</div>
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
)
}
@@ -1,221 +0,0 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreHorizontal,
Users,
Hash
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
</div>
{/* Search */}
<div className="p-4 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
{/* Actions menu */}
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
togglePin(conversation.id)
}}
className="cursor-pointer"
>
<Pin className="h-4 w-4 mr-2" />
{conversation.isPinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleMute(conversation.id)
}}
className="cursor-pointer"
>
<VolumeX className="h-4 w-4 mr-2" />
{conversation.isMuted ? "Unmute" : "Mute"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -1,208 +0,0 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreVertical,
Users,
Hash,
Settings,
UserPlus,
Filter
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header - Hidden on mobile (handled by parent) */}
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<UserPlus className="h-4 w-4 mr-2" />
New Chat
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Filter className="h-4 w-4 mr-2" />
Filter Messages
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Chat Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Search */}
<div className="px-4 py-3 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -1,225 +0,0 @@
"use client"
import { useState, useRef } from "react"
import {
Send,
Paperclip,
Smile,
Image as ImageIcon,
FileText,
Mic,
MoreHorizontal
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
interface MessageInputProps {
onSendMessage: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function MessageInput({
onSendMessage,
disabled = false,
placeholder = "Type a message..."
}: MessageInputProps) {
const [message, setMessage] = useState("")
const [isTyping, setIsTyping] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSendMessage = () => {
const trimmedMessage = message.trim()
if (trimmedMessage && !disabled) {
onSendMessage(trimmedMessage)
setMessage("")
setIsTyping(false)
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
}
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setMessage(value)
// Auto-resize textarea
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
}
// Handle typing indicator
if (value.trim() && !isTyping) {
setIsTyping(true)
} else if (!value.trim() && isTyping) {
setIsTyping(false)
}
}
const handleFileUpload = (type: "image" | "file") => {
// In a real app, this would open a file picker
console.log(`Upload ${type}`)
}
return (
<div className="border-t p-4">
<div className="flex items-end gap-2">
{/* Attachment button */}
<TooltipProvider>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Paperclip className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Attach file</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onClick={() => handleFileUpload("image")}
className="cursor-pointer"
>
<ImageIcon className="h-4 w-4 mr-2" />
Photo or video
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileUpload("file")}
className="cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Document
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
{/* Message input */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
placeholder={placeholder}
value={message}
onChange={handleTextareaChange}
onKeyDown={handleKeyPress}
disabled={disabled}
className={cn(
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
"pr-20" // Space for emoji and more buttons
)}
rows={1}
/>
{/* Input action buttons */}
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<Smile className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add emoji</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>More options</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Voice message or send button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{message.trim() ? (
<Button
onClick={handleSendMessage}
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Mic className="h-4 w-4" />
</Button>
)}
</TooltipTrigger>
<TooltipContent>
<p>{message.trim() ? "Send message" : "Voice message"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Typing indicator */}
{isTyping && (
<div className="text-xs text-muted-foreground mt-2">
You are typing...
</div>
)}
</div>
)
}
@@ -1,295 +0,0 @@
"use client"
import { useEffect, useRef } from "react"
import { format, isToday, isYesterday } from "date-fns"
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { type Message, type User } from "../use-chat"
interface MessageListProps {
messages: Message[]
users: User[]
currentUserId?: string
}
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const previousMessageCountRef = useRef(0)
const isInitialLoadRef = useRef(true)
const previousConversationRef = useRef<string | null>(null)
// Reset scroll behavior when switching conversations
useEffect(() => {
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
if (currentConversationId !== previousConversationRef.current) {
isInitialLoadRef.current = true
previousConversationRef.current = currentConversationId
}
}, [messages])
// Auto-scroll to bottom only when new messages are added (not on initial load)
useEffect(() => {
// Skip auto-scroll on initial load
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false
previousMessageCountRef.current = messages.length
return
}
// Only auto-scroll if new messages were added
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" })
}
previousMessageCountRef.current = messages.length
}, [messages])
const getUserById = (userId: string) => {
if (userId === currentUserId) {
return {
id: currentUserId,
name: "You",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-7",
status: "online" as const,
email: "you@example.com",
lastSeen: new Date().toISOString(),
role: "Developer",
department: "Engineering"
}
}
return users.find(user => user.id === userId)
}
const formatMessageTime = (timestamp: string) => {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, "HH:mm")
} else if (isYesterday(date)) {
return `Yesterday ${format(date, "HH:mm")}`
} else {
return format(date, "MMM d, HH:mm")
}
}
const shouldShowAvatar = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const shouldShowName = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const isConsecutiveMessage = (message: Message, index: number) => {
if (index === 0) return false
const prevMessage = messages[index - 1]
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
}
const groupMessagesByDay = (messages: Message[]) => {
const groups: { date: string; messages: Message[] }[] = []
messages.forEach((message) => {
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.date === messageDate) {
lastGroup.messages.push(message)
} else {
groups.push({
date: messageDate,
messages: [message]
})
}
})
return groups
}
const formatDateHeader = (dateString: string) => {
const date = new Date(dateString)
if (isToday(date)) {
return "Today"
} else if (isYesterday(date)) {
return "Yesterday"
} else {
return format(date, "EEEE, MMMM d")
}
}
const messageGroups = groupMessagesByDay(messages)
return (
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
<div className="space-y-4 py-4">
{messageGroups.map((group) => (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center justify-center py-2">
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
{formatDateHeader(group.date)}
</div>
</div>
{/* Messages for this day */}
<div className="space-y-1">
{group.messages.map((message, messageIndex) => {
const user = getUserById(message.senderId)
const isOwnMessage = message.senderId === currentUserId
const showAvatar = shouldShowAvatar(message, messageIndex)
const showName = shouldShowName(message, messageIndex)
const isConsecutive = isConsecutiveMessage(message, messageIndex)
return (
<div
key={message.id}
className={cn(
"flex gap-3 group",
isOwnMessage && "flex-row-reverse",
isConsecutive && !isOwnMessage && "ml-12"
)}
>
{/* Avatar */}
{!isOwnMessage && (
<div className="w-8">
{showAvatar && user && (
<Avatar className="h-8 w-8 cursor-pointer">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="text-xs">
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
</AvatarFallback>
</Avatar>
)}
</div>
)}
{/* Message content */}
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
{/* Sender name for group messages */}
{showName && user && !isOwnMessage && (
<div className="text-sm font-medium text-foreground mb-1">
{user.name}
</div>
)}
{/* Message bubble */}
<div className="relative group/message">
<div
className={cn(
"rounded-lg px-3 py-2 text-sm break-words",
isOwnMessage
? "bg-primary text-primary-foreground"
: "bg-muted",
isConsecutive && "mt-1"
)}
>
<p>{message.content}</p>
{/* Message reactions */}
{message.reactions.length > 0 && (
<div className="flex gap-1 mt-2">
{message.reactions.map((reaction, idx) => (
<div
key={idx}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
"bg-background/90 backdrop-blur-sm shadow-sm"
)}
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</div>
))}
</div>
)}
{/* Timestamp and status */}
<div className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwnMessage
? "text-primary-foreground/70 justify-end"
: "text-muted-foreground"
)}>
<span>{formatMessageTime(message.timestamp)}</span>
{message.isEdited && (
<span className="italic">(edited)</span>
)}
{isOwnMessage && (
<div className="flex">
{/* Message status indicators */}
<CheckCheck className="h-3 w-3" />
</div>
)}
</div>
</div>
{/* Message actions */}
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<Reply className="h-4 w-4 mr-2" />
Reply
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Copy className="h-4 w-4 mr-2" />
Copy
</DropdownMenuItem>
{isOwnMessage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
{/* Scroll anchor */}
<div ref={bottomRef} />
</div>
</ScrollArea>
)
}
@@ -1,96 +0,0 @@
[
{
"id": "conv-1",
"type": "direct",
"participants": ["1"],
"name": "Sarah Mitchell",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"lastMessage": {
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2025-08-11T15:30:00Z",
"senderId": "1"
},
"unreadCount": 2,
"isPinned": true,
"isMuted": false
},
{
"id": "conv-2",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Project Alpha",
"lastMessage": {
"id": "msg-2-8",
"content": "David: Marketing campaign is scheduled for next week",
"timestamp": "2025-08-11T08:15:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-3",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Frontend Team",
"lastMessage": {
"id": "msg-3-6",
"content": "Alex: The new component library is ready for testing",
"timestamp": "2025-08-11T23:45:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-4",
"type": "direct",
"participants": ["3"],
"name": "Emily Rodriguez",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"lastMessage": {
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-5",
"type": "direct",
"participants": ["5"],
"name": "Lisa Chen",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"lastMessage": {
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": true
},
{
"id": "conv-6",
"type": "direct",
"participants": ["2"],
"name": "Alex Thompson",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"lastMessage": {
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
}
]
-224
View File
@@ -1,224 +0,0 @@
{
"conv-1": [
{
"id": "msg-1-1",
"content": "Hey! How's the new dashboard coming along?",
"timestamp": "2024-01-15T10:15:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-2",
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
"timestamp": "2024-01-15T10:17:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
"replyTo": null
},
{
"id": "msg-1-3",
"content": "That's awesome! Can you share a preview?",
"timestamp": "2024-01-15T10:18:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2024-01-15T10:30:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-2": [
{
"id": "msg-2-1",
"content": "Hey team! The component library update is ready",
"timestamp": "2024-01-15T09:00:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-2",
"content": "Awesome work Alex! 🚀",
"timestamp": "2024-01-15T09:05:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-3",
"content": "I've tested the new Button and Input components, they work perfectly",
"timestamp": "2024-01-15T09:10:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
"replyTo": null
},
{
"id": "msg-2-4",
"content": "Great! I'll start integrating them into the main app",
"timestamp": "2024-01-15T09:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-3": [
{
"id": "msg-3-1",
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
"timestamp": "2024-01-15T09:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-2",
"content": "That's fantastic Emily! When can we review them?",
"timestamp": "2024-01-15T09:32:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-3",
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
"timestamp": "2024-01-15T09:35:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
},
{
"id": "msg-3-4",
"content": "Perfect! Looking forward to it",
"timestamp": "2024-01-15T09:40:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-4": [
{
"id": "msg-4-1",
"content": "Hi! I've been working on the wireframes for the new feature",
"timestamp": "2025-08-10T14:15:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-2",
"content": "That's great! I'd love to take a look at them",
"timestamp": "2025-08-10T14:18:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-5": [
{
"id": "msg-5-1",
"content": "I've been testing the new feature and it looks good overall",
"timestamp": "2025-08-06T13:45:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-2",
"content": "Thanks for testing it! Any issues you found?",
"timestamp": "2025-08-06T14:10:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-6": [
{
"id": "msg-6-1",
"content": "Hey! I've finished the code review for the latest PR",
"timestamp": "2025-01-15T16:30:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-2",
"content": "Thanks for the quick review! Any feedback?",
"timestamp": "2025-01-15T17:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
"replyTo": null
}
]
}
-52
View File
@@ -1,52 +0,0 @@
[
{
"id": "1",
"name": "Sarah Mitchell",
"email": "sarah.mitchell@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"status": "online",
"lastSeen": "2024-01-15T10:30:00Z",
"role": "Project Manager",
"department": "Product"
},
{
"id": "2",
"name": "Alex Thompson",
"email": "alex.thompson@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"status": "away",
"lastSeen": "2024-01-15T09:45:00Z",
"role": "Senior Developer",
"department": "Engineering"
},
{
"id": "3",
"name": "Emily Rodriguez",
"email": "emily.rodriguez@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"status": "online",
"lastSeen": "2024-01-15T10:25:00Z",
"role": "UX Designer",
"department": "Design"
},
{
"id": "4",
"name": "David Kim",
"email": "david.kim@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
"status": "offline",
"lastSeen": "2024-01-14T18:30:00Z",
"role": "Marketing Lead",
"department": "Marketing"
},
{
"id": "5",
"name": "Lisa Chen",
"email": "lisa.chen@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"status": "online",
"lastSeen": "2024-01-15T10:20:00Z",
"role": "QA Engineer",
"department": "Engineering"
}
]
-53
View File
@@ -1,53 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Chat } from "./components/chat"
import { type Conversation, type Message, type User } from "./use-chat"
// Import static data
import conversationsData from "./data/conversations.json"
import messagesData from "./data/messages.json"
import usersData from "./data/users.json"
export default function ChatPage() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [messages, setMessages] = useState<Record<string, Message[]>>({})
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadData = async () => {
try {
// In a real app, these would be API calls
setConversations(conversationsData as Conversation[])
setMessages(messagesData as Record<string, Message[]>)
setUsers(usersData as User[])
} catch (error) {
console.error("Failed to load chat data:", error)
} finally {
setLoading(false)
}
}
loadData()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Loading chat...</div>
</div>
)
}
return (
<div className="px-4 md:px-6">
<Chat
conversations={conversations}
messages={messages}
users={users}
/>
</div>
)
}
-149
View File
@@ -1,149 +0,0 @@
"use client"
import { create } from "zustand"
export interface User {
id: string
name: string
email: string
avatar: string
status: "online" | "away" | "offline"
lastSeen: string
role: string
department: string
}
export interface Message {
id: string
content: string
timestamp: string
senderId: string
type: "text" | "image" | "file"
isEdited: boolean
reactions: Array<{
emoji: string
users: string[]
count: number
}>
replyTo: string | null
}
export interface Conversation {
id: string
type: "direct" | "group"
participants: string[]
name: string
avatar: string
lastMessage: {
id: string
content: string
timestamp: string
senderId: string
}
unreadCount: number
isPinned: boolean
isMuted: boolean
}
interface ChatState {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
selectedConversation: string | null
searchQuery: string
isTyping: Record<string, boolean>
onlineUsers: string[]
}
interface ChatActions {
setConversations: (conversations: Conversation[]) => void
setMessages: (conversationId: string, messages: Message[]) => void
setUsers: (users: User[]) => void
setSelectedConversation: (conversationId: string | null) => void
setSearchQuery: (query: string) => void
addMessage: (conversationId: string, message: Message) => void
markAsRead: (conversationId: string) => void
togglePin: (conversationId: string) => void
toggleMute: (conversationId: string) => void
setTyping: (conversationId: string, isTyping: boolean) => void
setOnlineUsers: (userIds: string[]) => void
}
export const useChat = create<ChatState & ChatActions>((set, get) => ({
// State
conversations: [],
messages: {},
users: [],
selectedConversation: null,
searchQuery: "",
isTyping: {},
onlineUsers: [],
// Actions
setConversations: (conversations) => set({ conversations }),
setMessages: (conversationId, messages) =>
set((state) => ({
messages: { ...state.messages, [conversationId]: messages }
})),
setUsers: (users) => set({ users }),
setSelectedConversation: (conversationId) => {
set({ selectedConversation: conversationId })
if (conversationId) {
get().markAsRead(conversationId)
}
},
setSearchQuery: (query) => set({ searchQuery: query }),
addMessage: (conversationId, message) =>
set((state) => ({
messages: {
...state.messages,
[conversationId]: [...(state.messages[conversationId] || []), message]
},
conversations: state.conversations.map((conv) =>
conv.id === conversationId
? {
...conv,
lastMessage: {
id: message.id,
content: message.content,
timestamp: message.timestamp,
senderId: message.senderId
}
}
: conv
)
})),
markAsRead: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
)
})),
togglePin: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
)
})),
toggleMute: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
)
})),
setTyping: (conversationId, isTyping) =>
set((state) => ({
isTyping: { ...state.isTyping, [conversationId]: isTyping }
})),
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
}))
@@ -1,196 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import {
createCustomerAction,
updateCustomerAction,
} from "@/lib/appwrite/customer-actions";
import { initialCustomerState } from "@/lib/appwrite/customer-types";
import type { CustomerRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
customer?: CustomerRow | null;
};
export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
const isEdit = Boolean(customer);
const action = isEdit ? updateCustomerAction : createCustomerAction;
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Müşteriyi düzenle" : "Yeni müşteri"}</SheetTitle>
<SheetDescription>
{isEdit
? "Müşteri bilgilerini güncelleyin."
: "Yeni bir müşteri ekleyin. * işaretli alanlar zorunludur."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && customer && <input type="hidden" name="id" value={customer.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="name">Ad / Şirket adı *</Label>
<Input
id="name"
name="name"
defaultValue={customer?.name ?? ""}
placeholder="Örn. Acme Yazılım Ltd."
required
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={customer?.email ?? ""}
placeholder="info@acme.com"
/>
{state.fieldErrors?.email && (
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={customer?.phone ?? ""}
placeholder="+90 555 123 45 67"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="taxId">Vergi numarası</Label>
<Input
id="taxId"
name="taxId"
defaultValue={customer?.taxId ?? ""}
placeholder="1234567890"
inputMode="numeric"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={customer?.status ?? "active"}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="passive">Pasif</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="address">Adres</Label>
<Textarea
id="address"
name="address"
rows={2}
defaultValue={customer?.address ?? ""}
placeholder="Açık adres"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={4}
defaultValue={customer?.notes ?? ""}
placeholder="Müşteriye özel notlar"
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet>
);
}
@@ -1,314 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpDown,
ChevronLeft,
ChevronRight,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
UserPlus,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CustomerFormSheet } from "./customer-form-sheet";
import { DeleteCustomerDialog } from "./delete-customer-dialog";
import type { CustomerRow } from "./types";
type Props = { customers: CustomerRow[] };
export function CustomersClient({ customers }: Props) {
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<CustomerRow | null>(null);
const [deleting, setDeleting] = useState<CustomerRow | null>(null);
const columns = useMemo<ColumnDef<CustomerRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
İsim
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.original.email ? (
<a
href={`mailto:${row.original.email}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{row.original.email}
</a>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "phone",
header: "Telefon",
cell: ({ row }) =>
row.original.phone ? (
<a
href={`tel:${row.original.phone}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{row.original.phone}
</a>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "status",
header: "Durum",
cell: ({ row }) => (
<Badge variant={row.original.status === "active" ? "default" : "secondary"}>
{row.original.status === "active" ? "Aktif" : "Pasif"}
</Badge>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Eklendi
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{new Date(row.original.createdAt).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
})}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditing(row.original);
setFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: customers,
columns,
state: { globalFilter, sorting },
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
globalFilterFn: (row, _id, filterValue) => {
const v = String(filterValue).toLowerCase();
return [row.original.name, row.original.email, row.original.phone, row.original.taxId]
.join(" ")
.toLowerCase()
.includes(v);
},
});
return (
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="İsim, email, telefon, vergi no..."
className="pl-9"
/>
</div>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni müşteri
</Button>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<UserPlus className="size-6" />
<p className="text-sm">Henüz müşteri eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk müşteriyi ekle
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t px-4 py-3">
<p className="text-muted-foreground text-sm">
Toplam {table.getFilteredRowModel().rows.length} müşteri
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm">
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</CardContent>
<CustomerFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
customer={editing}
/>
<DeleteCustomerDialog
open={Boolean(deleting)}
onOpenChange={(v) => !v && setDeleting(null)}
id={deleting?.id ?? null}
name={deleting?.name ?? ""}
/>
</Card>
);
}
@@ -1,76 +0,0 @@
"use client";
import { useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
export function DeleteCustomerDialog({
open,
onOpenChange,
id,
name,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
id: string | null;
name: string;
}) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!id) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
const result = await deleteCustomerAction(fd);
if (result.ok) {
toast.success("Müşteri silindi.");
onOpenChange(false);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Müşteriyi sil</DialogTitle>
<DialogDescription>
<strong>{name}</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Siliniyor...
</>
) : (
<>
<Trash2 className="size-4" />
Sil
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,11 +0,0 @@
export type CustomerRow = {
id: string;
name: string;
email: string;
phone: string;
taxId: string;
address: string;
notes: string;
status: "active" | "passive";
createdAt: string;
};
@@ -0,0 +1,8 @@
export default function MatchesPage() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
+4 -50
View File
@@ -1,54 +1,8 @@
import type { Metadata } from "next"; export default function CustomersPage() {
import { redirect } from "next/navigation";
import { UsageBanner } from "@/components/billing/usage-banner";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CustomersClient } from "./components/customers-client";
export const metadata: Metadata = {
title: "İşletmem — Müşteriler",
};
export default async function CustomersPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [customers, usage] = await Promise.all([
listCustomers(ctx.tenantId),
getPlanUsage(ctx),
]);
return ( return (
<div className="flex-1 space-y-6 px-6 pt-0"> <div className="flex flex-1 flex-col gap-4 p-4">
<div className="flex flex-col gap-1"> <h1 className="text-2xl font-bold">Müşteriler</h1>
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p> <p className="text-muted-foreground">Yakında...</p>
<h1 className="text-2xl font-bold tracking-tight">Müşteriler</h1>
<p className="text-muted-foreground text-sm">
Müşterilerinizi yönetin, hizmet ve yazılım ilişkilerini buradan kurun.
</p>
</div>
<UsageBanner usage={usage} resource="customers" />
<CustomersClient
customers={customers.map((c) => ({
id: c.$id,
name: c.name,
email: c.email ?? "",
phone: c.phone ?? "",
taxId: c.taxId ?? "",
address: c.address ?? "",
notes: c.notes ?? "",
status: (c.status ?? "active") as "active" | "passive",
createdAt: c.$createdAt,
}))}
/>
</div> </div>
); );
} }
@@ -0,0 +1,8 @@
export default function SearchesPage() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold">Arama Kriterleri</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
@@ -1,59 +0,0 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type Point = { month: string; count: number };
export function CustomerGrowth({ data }: { data: Point[] }) {
const total = data.reduce((s, p) => s + p.count, 0);
return (
<Card>
<CardHeader>
<CardTitle>Yeni müşteriler</CardTitle>
<CardDescription>Son 6 ay toplam {total} yeni müşteri</CardDescription>
</CardHeader>
<CardContent className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted))" }}
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: unknown) => [`${value} müşteri`, "Yeni"]}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -1,90 +0,0 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
type Point = { month: string; income: number; expense: number };
export function IncomeChart({ data }: { data: Point[] }) {
const total = data.reduce((s, p) => s + p.income, 0);
return (
<Card className="@container">
<CardHeader>
<CardTitle>Gelir / Gider</CardTitle>
<CardDescription>
Son 12 ay toplam gelir {formatTRY(total)}
</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v) =>
v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v)
}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: number, name: string) => [
formatTRY(value),
name === "income" ? "Gelir" : "Gider",
]}
/>
<Area
type="monotone"
dataKey="income"
stroke="#10b981"
strokeWidth={2}
fill="url(#incomeGradient)"
/>
<Area
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
fill="url(#expenseGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -1,116 +0,0 @@
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
CheckSquare,
Receipt,
Users,
Wallet,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
function delta(current: number, previous: number): { pct: number; positive: boolean } | null {
if (previous === 0) {
if (current === 0) return null;
return { pct: 100, positive: true };
}
const pct = ((current - previous) / previous) * 100;
return { pct: Math.abs(pct), positive: pct >= 0 };
}
export function Metrics({ data }: { data: DashboardData["metrics"] }) {
const incomeDelta = delta(data.monthIncome, data.prevMonthIncome);
const cards = [
{
label: "Müşteriler",
value: String(data.totalCustomers),
sub: `${data.activeCustomers} aktif`,
icon: Users,
tone: "default",
},
{
label: "Bu ayki gelir",
value: formatTRY(data.monthIncome),
sub: incomeDelta
? `${incomeDelta.positive ? "+" : ""}${incomeDelta.pct.toFixed(1)}% önceki ay`
: "Geçen ay veri yok",
icon: Wallet,
tone: "income",
trend: incomeDelta,
},
{
label: "Bekleyen tahsilat",
value: formatTRY(data.outstanding),
sub:
data.overdueCount > 0
? `${data.overdueCount} vadesi geçmiş`
: "Vadesi geçmiş yok",
icon: Receipt,
tone: data.overdueCount > 0 ? "warning" : "default",
},
{
label: "Açık görevlerim",
value: String(data.openTasks),
sub:
data.urgentTasks > 0
? `${data.urgentTasks} acil`
: data.openTasks === 0
? "Hepsi tamam"
: "Atanmış + atanmamış",
icon: CheckSquare,
tone: data.urgentTasks > 0 ? "warning" : "default",
},
];
const toneClass: Record<string, string> = {
default: "text-muted-foreground",
income: "text-emerald-600 dark:text-emerald-400",
warning: "text-amber-600 dark:text-amber-400",
};
return (
<div className="grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card key={c.label}>
<CardContent className="flex items-start justify-between p-5">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{c.label}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{c.value}</p>
<p
className={cn(
"mt-1 flex items-center gap-1 text-xs",
c.tone === "warning" && data.overdueCount + data.urgentTasks > 0
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{c.trend &&
(c.trend.positive ? (
<ArrowUpRight className="text-emerald-600 dark:text-emerald-400 size-3" />
) : (
<ArrowDownRight className="text-red-600 dark:text-red-400 size-3" />
))}
{c.tone === "warning" && data.overdueCount + data.urgentTasks > 0 && (
<AlertCircle className="size-3" />
)}
{c.sub}
</p>
</div>
<Icon className={cn("size-5", toneClass[c.tone])} />
</CardContent>
</Card>
);
})}
</div>
);
}
@@ -1,82 +0,0 @@
import Link from "next/link";
import { ArrowRight, Receipt } 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 { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
const TYPE_LABEL: Record<string, string> = {
income: "Gelir",
expense: "Gider",
debt: "Borç",
receivable: "Alacak",
};
const TYPE_COLOR: Record<string, string> = {
income: "text-emerald-600 dark:text-emerald-400",
expense: "text-red-600 dark:text-red-400",
debt: "text-amber-600 dark:text-amber-400",
receivable: "text-blue-600 dark:text-blue-400",
};
export function RecentTransactions({
data,
}: {
data: DashboardData["recentTransactions"];
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Son işlemler</CardTitle>
<CardDescription>En son finans hareketleri</CardDescription>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/finance">
Tümü <ArrowRight className="size-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
<Receipt className="size-6" />
<p>Henüz finans hareketi yok.</p>
</div>
) : (
<ul className="divide-y">
{data.map((t) => {
const sign =
t.type === "income" || t.type === "receivable" ? "+" : "";
return (
<li key={t.id} className="flex items-center justify-between py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{TYPE_LABEL[t.type]}
</Badge>
<span className="text-muted-foreground text-xs">
{formatDate(t.date)}
</span>
</div>
<p className="mt-0.5 truncate text-sm">
{t.customerName ? `${t.customerName}` : ""}
{t.description || "—"}
</p>
</div>
<span className={cn("font-medium tabular-nums", TYPE_COLOR[t.type])}>
{sign} {formatTRY(t.amount)}
</span>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -1,63 +0,0 @@
import { Crown, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
type Item = { name: string; total: number };
export function TopCustomers({ data }: { data: Item[] }) {
const max = data[0]?.total ?? 1;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown className="size-4" />
En çok ciro yapan müşteriler
</CardTitle>
<CardDescription>Ödenmiş faturaların toplam tutarına göre</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
<TrendingUp className="size-6" />
<p>Henüz ödenmiş fatura yok.</p>
</div>
) : (
<ul className="space-y-3">
{data.map((c, i) => {
const width = (c.total / max) * 100;
return (
<li key={c.name + i} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium">
<span className="text-muted-foreground mr-2 tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
{c.name}
</span>
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
"h-full rounded-full",
i === 0
? "bg-emerald-500"
: i === 1
? "bg-emerald-400"
: "bg-emerald-300",
)}
style={{ width: `${width}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
+20 -32
View File
@@ -1,50 +1,38 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context"; import { getActiveContext } from "@/lib/appwrite/active-context";
import { getDashboardData } from "@/lib/appwrite/dashboard-queries";
import { CustomerGrowth } from "./components/customer-growth";
import { IncomeChart } from "./components/income-chart";
import { Metrics } from "./components/metrics";
import { QuickActions } from "./components/quick-actions";
import { RecentTransactions } from "./components/recent-transactions";
import { TopCustomers } from "./components/top-customers";
export default async function DashboardPage() { export default async function DashboardPage() {
const ctx = await getActiveContext(); const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding"); if (!ctx) redirect("/onboarding");
const data = await getDashboardData(ctx.tenantId, ctx.user.id);
const firstName = ctx.user.name?.split(" ")[0] ?? ""; const firstName = ctx.user.name?.split(" ")[0] ?? "";
const companyName = ctx.settings?.companyName ?? "Çalışma alanı"; const officeName = ctx.settings?.officeName ?? "Çalışma alanı";
return ( return (
<div className="flex-1 space-y-6 px-6 pt-0"> <div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center md:gap-6"> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <p className="text-muted-foreground text-sm">{officeName}</p>
<p className="text-muted-foreground text-sm">{companyName}</p> <h1 className="text-2xl font-bold tracking-tight">
<h1 className="text-2xl font-bold tracking-tight"> {firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel bakış"} </h1>
</h1> <p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm"> Portföyünüzü ve müşteri aktivitelerini buradan takip edin.
İşletmenizin temel metriklerini ve son hareketleri buradan takip edin. </p>
</p>
</div>
<QuickActions />
</div> </div>
<div className="@container/main space-y-6"> <div className="grid gap-4 md:grid-cols-3">
<Metrics data={data.metrics} /> <div className="bg-card rounded-xl border p-6">
<p className="text-muted-foreground text-sm">Aktif İlanlar</p>
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2"> <p className="mt-2 text-3xl font-bold"></p>
<IncomeChart data={data.monthlyIncome} />
<TopCustomers data={data.topCustomers} />
</div> </div>
<div className="bg-card rounded-xl border p-6">
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2"> <p className="text-muted-foreground text-sm">Müşteriler</p>
<RecentTransactions data={data.recentTransactions} /> <p className="mt-2 text-3xl font-bold"></p>
<CustomerGrowth data={data.newCustomersMonthly} /> </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> </div>
</div> </div>
</div> </div>
@@ -1,129 +0,0 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { cn } from "@/lib/utils"
import { Search } from "lucide-react"
interface FAQ {
id: number
question: string
answer: string
category: string
}
interface Category {
name: string
count: number
}
interface FAQListProps {
faqs: FAQ[]
categories: Category[]
}
export function FAQList({ faqs, categories }: FAQListProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [searchQuery, setSearchQuery] = useState("")
// Filter FAQs based on selected category and search query
const filteredFaqs = faqs.filter(faq => {
const matchesCategory = selectedCategory === "All" || faq.category === selectedCategory
const matchesSearch = searchQuery === "" ||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
return (
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-4 gap-6">
{/* Categories Sidebar */}
<Card className="lg:col-span-2 xl:col-span-1">
<CardHeader>
<CardTitle className="text-lg">Categories</CardTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search FAQs..."
className="pl-10 cursor-pointer"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="space-y-2">
{categories.map((category) => (
<div
key={category.name}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg hover:bg-muted cursor-pointer transition-colors group",
selectedCategory === category.name && "bg-muted"
)}
onClick={() => setSelectedCategory(category.name)}
>
<span className="font-medium">{category.name}</span>
<Badge
variant="secondary"
className={cn(
"transition-colors",
selectedCategory === category.name && "bg-background"
)}
>
{category.name === "All" ? faqs.length : category.count}
</Badge>
</div>
))}
</CardContent>
</Card>
{/* FAQs List */}
<div className="lg:col-span-4 xl:col-span-3">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{selectedCategory === "All" ? "All FAQs" : `${selectedCategory} FAQs`}
<span className="text-sm font-normal text-muted-foreground ml-2">
({filteredFaqs.length} {filteredFaqs.length === 1 ? 'question' : 'questions'})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[570px] pr-4">
{filteredFaqs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No FAQs found matching your search criteria.</p>
</div>
) : (
<Accordion type='single' className='space-y-4' defaultValue="item-1">
{filteredFaqs.map((item) => (
<AccordionItem
key={item.id}
value={`item-${item.id}`}
className='rounded-md !border'
>
<AccordionTrigger className='cursor-pointer px-4 hover:no-underline'>
<div className="flex items-start text-left">
<span>{item.question}</span>
<Badge variant="outline" className="ms-3 mt-0.5 shrink-0 text-xs">
{item.category}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className='text-muted-foreground px-4'>
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -1,54 +0,0 @@
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from '@/components/ui/button'
import { ArrowRight, Sparkles, Shield, Truck, Clock } from 'lucide-react'
interface FeatureItem {
id: number
title: string
description: string
icon: string
}
interface FeaturesGridProps {
features: FeatureItem[]
}
const iconMap = {
Sparkles,
Shield,
Truck,
Clock,
}
export function FeaturesGrid({ features }: FeaturesGridProps) {
return (
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 xl:grid-cols-4 mt-8'>
{features.map(feature => {
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
return (
<article key={feature.id} className='group'>
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
<CardContent className='px-6'>
<Badge variant='secondary' className='mb-4 inline-flex size-12 items-center justify-center'>
<IconComponent className='!size-5' aria-hidden='true' />
</Badge>
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
<Button
variant='link'
size='sm'
className='text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
>
Learn more
<ArrowRight className='ms-1.5 size-4' />
</Button>
</CardContent>
</Card>
</article>
)
})}
</div>
)
}
@@ -1,10 +0,0 @@
[
{ "name": "All", "count": 46 },
{ "name": "General", "count": 8 },
{ "name": "Account", "count": 6 },
{ "name": "Billing", "count": 8 },
{ "name": "Technical", "count": 9 },
{ "name": "Privacy", "count": 5 },
{ "name": "Security", "count": 4 },
{ "name": "Support", "count": 6 }
]
-278
View File
@@ -1,278 +0,0 @@
[
{
"id": 1,
"question": "What is ShadcnStore Admin?",
"answer": "ShadcnStore Admin is a comprehensive admin dashboard template built with React, TypeScript, and shadcn/ui components. It provides a complete solution for managing your e-commerce store or business operations.",
"category": "General"
},
{
"id": 2,
"question": "How do I get started?",
"answer": "You can get started by signing up for an account, choosing a plan that fits your needs, and following our quick setup guide to configure your dashboard.",
"category": "General"
},
{
"id": 3,
"question": "Do you offer a free trial?",
"answer": "Yes, we offer a 14-day free trial for all new users. No credit card is required to start the trial, and you can explore all features during this period.",
"category": "General"
},
{
"id": 4,
"question": "What browsers are supported?",
"answer": "We support all modern browsers including Chrome, Firefox, Safari, and Edge. For the best experience, we recommend using the latest version of your preferred browser.",
"category": "General"
},
{
"id": 5,
"question": "How do I contact support?",
"answer": "You can contact our support team through the support page, by email at support@shadcnstore.com, or through the live chat feature available 24/7.",
"category": "General"
},
{
"id": 6,
"question": "Is there a mobile app available?",
"answer": "Currently, we offer a responsive web application that works great on mobile devices. A dedicated mobile app is planned for future release.",
"category": "General"
},
{
"id": 7,
"question": "Can I customize the dashboard?",
"answer": "Yes, the dashboard is highly customizable. You can modify themes, layouts, add custom components, and configure various settings to match your brand.",
"category": "General"
},
{
"id": 8,
"question": "What integrations are available?",
"answer": "We offer integrations with popular services like Stripe, PayPal, Shopify, WooCommerce, Google Analytics, and many more through our API.",
"category": "General"
},
{
"id": 9,
"question": "How do I reset my password?",
"answer": "You can reset your password by clicking on the 'Forgot Password' link on the login page. Enter your email address, and we'll send you instructions to reset your password.",
"category": "Account"
},
{
"id": 10,
"question": "How do I change my email address?",
"answer": "You can change your email address in your account settings under the 'User Settings' section. You'll need to verify the new email address before the change takes effect.",
"category": "Account"
},
{
"id": 11,
"question": "Can I have multiple team members?",
"answer": "Yes, depending on your plan, you can invite team members and assign different roles and permissions to manage your store collaboratively.",
"category": "Account"
},
{
"id": 12,
"question": "How do I delete my account?",
"answer": "To delete your account, go to your account settings and select 'Delete Account'. Please note that this action is irreversible and all data will be permanently removed.",
"category": "Account"
},
{
"id": 13,
"question": "Can I change my username?",
"answer": "Yes, you can change your username in the account settings. Keep in mind that some features might reference your old username temporarily.",
"category": "Account"
},
{
"id": 14,
"question": "How do I enable two-factor authentication?",
"answer": "You can enable two-factor authentication in your account security settings. We support both SMS and authenticator app methods for added security.",
"category": "Account"
},
{
"id": 15,
"question": "What payment methods do you accept?",
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely.",
"category": "Billing"
},
{
"id": 16,
"question": "How can I upgrade my plan?",
"answer": "You can upgrade your plan at any time from your account settings. Go to 'Plans & Billing' and select the plan that best fits your needs. Changes take effect immediately.",
"category": "Billing"
},
{
"id": 17,
"question": "Can I downgrade my plan?",
"answer": "Yes, you can downgrade your plan at any time. The change will take effect at the start of your next billing cycle to ensure you don't lose access to premium features.",
"category": "Billing"
},
{
"id": 18,
"question": "Do you offer refunds?",
"answer": "We offer a 30-day money-back guarantee for all plans. If you're not satisfied, contact our support team for a full refund within 30 days of purchase.",
"category": "Billing"
},
{
"id": 19,
"question": "How does billing work?",
"answer": "Billing is processed monthly or annually depending on your chosen plan. You'll receive an invoice before each billing cycle, and payment is automatically charged to your selected method.",
"category": "Billing"
},
{
"id": 20,
"question": "Can I change my billing cycle?",
"answer": "Yes, you can switch between monthly and annual billing at any time. Annual billing offers significant savings compared to monthly billing.",
"category": "Billing"
},
{
"id": 21,
"question": "What happens if payment fails?",
"answer": "If a payment fails, we'll attempt to charge your card again after 3 days. You'll receive email notifications, and your account will remain active during this grace period.",
"category": "Billing"
},
{
"id": 22,
"question": "How do I view my billing history?",
"answer": "You can view your complete billing history in the 'Plans & Billing' section of your account settings. All invoices and receipts are available for download.",
"category": "Billing"
},
{
"id": 23,
"question": "Can I export my data?",
"answer": "Yes, you can export your data at any time from your account settings. We provide exports in multiple formats including CSV, JSON, and PDF for different data types.",
"category": "Technical"
},
{
"id": 24,
"question": "What APIs do you provide?",
"answer": "We provide comprehensive REST APIs for all major features including product management, order processing, customer data, and analytics. Full documentation is available.",
"category": "Technical"
},
{
"id": 25,
"question": "How do I backup my data?",
"answer": "We automatically backup all your data daily. You can also create manual backups anytime from your settings, and restore from any backup point within the last 30 days.",
"category": "Technical"
},
{
"id": 26,
"question": "Is there a rate limit on API calls?",
"answer": "Yes, API rate limits vary by plan. Basic plans have 1000 calls/hour, Professional plans have 10,000 calls/hour, and Enterprise plans have unlimited calls.",
"category": "Technical"
},
{
"id": 27,
"question": "How do I set up webhooks?",
"answer": "Webhooks can be configured in the 'Connections' section of your settings. You can set up webhooks for various events like new orders, payment confirmations, and inventory updates.",
"category": "Technical"
},
{
"id": 28,
"question": "What about system maintenance?",
"answer": "We perform maintenance during low-traffic hours (typically Sunday 2-4 AM UTC). You'll be notified at least 48 hours in advance of any scheduled maintenance.",
"category": "Technical"
},
{
"id": 29,
"question": "How do I troubleshoot connection issues?",
"answer": "First, check your internet connection and try refreshing the page. If issues persist, check our status page or contact support with specific error messages.",
"category": "Technical"
},
{
"id": 30,
"question": "Can I use custom domains?",
"answer": "Yes, Professional and Enterprise plans support custom domains. You can configure your custom domain in the 'Connections' section of your account settings.",
"category": "Technical"
},
{
"id": 31,
"question": "What databases do you support?",
"answer": "We support integration with MySQL, PostgreSQL, MongoDB, and other popular databases through our Database Sync feature available in higher-tier plans.",
"category": "Technical"
},
{
"id": 32,
"question": "How do you handle my personal data?",
"answer": "We follow strict data protection policies and comply with GDPR, CCPA, and other privacy regulations. Your personal data is never shared with third parties without your consent.",
"category": "Privacy"
},
{
"id": 33,
"question": "Can I request my data?",
"answer": "Yes, you can request a complete copy of your personal data at any time. We'll provide it in a machine-readable format within 30 days of your request.",
"category": "Privacy"
},
{
"id": 34,
"question": "How long do you retain data?",
"answer": "We retain your data as long as your account is active. After account deletion, personal data is removed within 30 days, though some anonymized analytics may be retained.",
"category": "Privacy"
},
{
"id": 35,
"question": "Do you use cookies?",
"answer": "Yes, we use essential cookies for functionality and optional cookies for analytics and personalization. You can manage your cookie preferences in your account settings.",
"category": "Privacy"
},
{
"id": 36,
"question": "Is my data encrypted?",
"answer": "Yes, all data is encrypted both in transit (using TLS 1.3) and at rest (using AES-256 encryption). We use industry-standard security practices to protect your information.",
"category": "Privacy"
},
{
"id": 37,
"question": "How secure is my data?",
"answer": "We implement bank-level security with end-to-end encryption, regular security audits, and compliance with SOC 2 Type II standards. Your data security is our top priority.",
"category": "Security"
},
{
"id": 38,
"question": "Do you support SSO?",
"answer": "Yes, Enterprise plans include Single Sign-On (SSO) support with popular providers like Google, Microsoft Azure AD, and Okta for seamless team access.",
"category": "Security"
},
{
"id": 39,
"question": "What about password requirements?",
"answer": "We require strong passwords with at least 8 characters, including uppercase, lowercase, numbers, and special characters. We also highly recommend enabling two-factor authentication.",
"category": "Security"
},
{
"id": 40,
"question": "How do you handle security incidents?",
"answer": "We have a comprehensive incident response plan. In case of any security issues, we immediately investigate, contain the issue, and notify affected users within 24 hours.",
"category": "Security"
},
{
"id": 41,
"question": "What support channels are available?",
"answer": "We offer email support, live chat, and phone support (for Enterprise customers). Our knowledge base and community forums are also available 24/7.",
"category": "Support"
},
{
"id": 42,
"question": "What are your support hours?",
"answer": "Email and chat support are available 24/7. Phone support for Enterprise customers is available Monday-Friday, 9 AM-6 PM in your local timezone.",
"category": "Support"
},
{
"id": 43,
"question": "How quickly will I get a response?",
"answer": "Response times vary by plan: Basic (24 hours), Professional (12 hours), Enterprise (2 hours). Critical issues are prioritized and responded to immediately.",
"category": "Support"
},
{
"id": 44,
"question": "Do you offer training?",
"answer": "Yes, we provide comprehensive onboarding for all plans, video tutorials, documentation, and personalized training sessions for Enterprise customers.",
"category": "Support"
},
{
"id": 45,
"question": "Can you help with custom implementations?",
"answer": "Enterprise customers get access to our professional services team for custom implementations, integrations, and consulting services.",
"category": "Support"
},
{
"id": 46,
"question": "Is there a community forum?",
"answer": "Yes, we have an active community forum where users share tips, ask questions, and get help from both our team and other community members.",
"category": "Support"
}
]
@@ -1,26 +0,0 @@
[
{
"id": 1,
"title": "Premium Quality",
"description": "Handcrafted with premium materials and meticulous attention to detail.",
"icon": "Sparkles"
},
{
"id": 2,
"title": "Secure Shopping",
"description": "100% secure payment processing with end-to-end encryption.",
"icon": "Shield"
},
{
"id": 3,
"title": "Fast Delivery",
"description": "Free worldwide shipping and hassle-free returns within 30 days.",
"icon": "Truck"
},
{
"id": 4,
"title": "24/7 Support",
"description": "Round-the-clock customer support to assist you anytime.",
"icon": "Clock"
}
]
-16
View File
@@ -1,16 +0,0 @@
import { FAQList } from "./components/faq-list"
import { FeaturesGrid } from "./components/features-grid"
// Import data
import categoriesData from "./data/categories.json"
import faqsData from "./data/faqs.json"
import featuresData from "./data/features.json"
export default function FAQsPage() {
return (
<div className="px-4 lg:px-6">
<FAQList faqs={faqsData} categories={categoriesData} />
<FeaturesGrid features={featuresData} />
</div>
)
}
@@ -1,162 +0,0 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createBankAccountAction,
updateBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
account?: BankAccountRow | null;
};
export function BankFormSheet({ open, onOpenChange, account }: Props) {
const isEdit = Boolean(account);
const action = isEdit ? updateBankAccountAction : createBankAccountAction;
const [state, formAction, isPending] = useActionState(action, initialBankAccountState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Hesap güncellendi." : "Hesap eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Hesabı düzenle" : "Yeni banka hesabı"}</SheetTitle>
<SheetDescription>
Açılış bakiyesi sonradan değiştirilirse bütün hareketler aynı kalır, sadece toplam
kayar.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && account && <input type="hidden" name="id" value={account.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={(account as { scope?: "company" | "personal" } | null)?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input
id="bankName"
name="bankName"
defaultValue={account?.bankName ?? ""}
placeholder="Örn. Garanti BBVA"
required
/>
{state.fieldErrors?.bankName && (
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="accountName">Hesap adı *</Label>
<Input
id="accountName"
name="accountName"
defaultValue={account?.accountName ?? ""}
placeholder="Örn. Şirket TL Vadesiz"
required
/>
{state.fieldErrors?.accountName && (
<p className="text-destructive text-xs">{state.fieldErrors.accountName}</p>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="iban">IBAN</Label>
<Input
id="iban"
name="iban"
defaultValue={account?.iban ?? ""}
placeholder="TR.. .... .... .... .... .... .."
style={{ fontFamily: "monospace", textTransform: "uppercase" }}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="openingBalance">Açılış bakiyesi ()</Label>
<Input
id="openingBalance"
name="openingBalance"
type="number"
step="0.01"
defaultValue={account?.openingBalance ?? 0}
placeholder="0.00"
/>
<p className="text-muted-foreground text-xs">
Bu hesabı sisteme eklediğinizdeki bakiye. Sonraki hareketler bu rakamın üstüne eklenir.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={account?.notes ?? ""}
placeholder="Şube, yetkili, müşteri no, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,294 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
Archive,
ArchiveRestore,
Building2,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
archiveBankAccountAction,
deleteBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { formatTRY } from "@/lib/format";
import { ScopeBadge } from "@/components/finance/scope-toggle";
import { cn } from "@/lib/utils";
import { BankFormSheet } from "./bank-form-sheet";
import type { BankAccountRow } from "./types";
type Props = { accounts: BankAccountRow[] };
export function BanksClient({ accounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BankAccountRow | null>(null);
const [deleting, setDeleting] = useState<BankAccountRow | null>(null);
const [busy, startTransition] = useTransition();
const active = accounts.filter((a) => !a.archived);
const archived = accounts.filter((a) => a.archived);
const totalBalance = active.reduce((s, a) => s + a.balance, 0);
const toggleArchive = (acc: BankAccountRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", acc.id);
const result = await archiveBankAccountAction(fd);
if (result.ok) {
toast.success(acc.archived ? "Hesap geri açıldı." : "Hesap arşivlendi.");
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteBankAccountAction(fd);
if (result.ok) {
toast.success("Hesap silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<Card className="flex-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam bakiye (aktif hesaplar)</p>
<p
className={cn(
"mt-1 text-2xl font-semibold tabular-nums",
totalBalance >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(totalBalance)}
</p>
</CardContent>
</Card>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni hesap
</Button>
</div>
{active.length === 0 && archived.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Building2 className="text-muted-foreground size-8" />
<p className="text-sm">Henüz banka hesabı eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk hesabı ekle
</Button>
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{active.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş hesaplar ({archived.length})
</summary>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{archived.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
</details>
)}
</>
)}
<BankFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
account={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hesabı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.accountName}</strong> kalıcı olarak silinecek.
Bağlı finans hareketi varsa silme reddedilir; o durumda arşivlemeyi tercih edin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function AccountCard({
account,
onEdit,
onArchiveToggle,
onDelete,
busy,
}: {
account: BankAccountRow;
onEdit: () => void;
onArchiveToggle: () => void;
onDelete: () => void;
busy: boolean;
}) {
const positive = account.balance >= 0;
return (
<Card className={cn(account.archived && "opacity-60")}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Building2 className="text-muted-foreground size-4 shrink-0" />
<h3 className="truncate font-medium">{account.bankName}</h3>
{account.archived && (
<Badge variant="outline" className="text-[10px]">
Arşivli
</Badge>
)}
<ScopeBadge scope={account.scope} />
</div>
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
{account.iban && (
<p className="text-muted-foreground mt-1 truncate font-mono text-[11px]">
{account.iban.replace(/(.{4})/g, "$1 ").trim()}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 shrink-0" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={onArchiveToggle}>
{account.archived ? (
<>
<ArchiveRestore className="size-3.5" />
Arşivden çıkar
</>
) : (
<>
<Archive className="size-3.5" />
Arşivle
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<p className="text-muted-foreground text-xs">Güncel bakiye</p>
<p
className={cn(
"text-xl font-semibold tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(account.balance)}
</p>
{account.balance !== account.openingBalance && (
<p className="text-muted-foreground mt-0.5 text-[11px]">
Açılış: {formatTRY(account.openingBalance)}
</p>
)}
</div>
</CardContent>
</Card>
);
}
@@ -1,11 +0,0 @@
export type BankAccountRow = {
id: string;
bankName: string;
accountName: string;
iban: string;
openingBalance: number;
notes: string;
archived: boolean;
balance: number;
scope: "company" | "personal";
};
@@ -1,53 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
getBankAccountBalances,
listBankAccounts,
} from "@/lib/appwrite/bank-account-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { BanksClient } from "./components/banks-client";
export const metadata: Metadata = {
title: "İşletmem — Banka hesapları",
};
export default async function BanksPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [accounts, balances] = await Promise.all([
listBankAccounts(ctx.tenantId, ctx.user.id),
getBankAccountBalances(ctx.tenantId, ctx.user.id),
]);
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">Banka hesapları</h1>
<p className="text-muted-foreground text-sm">
İşletmenize ait banka hesaplarını ve güncel bakiyelerini takip edin.
</p>
</div>
<BanksClient
accounts={accounts.map((a) => ({
id: a.$id,
bankName: a.bankName,
accountName: a.accountName,
iban: a.iban ?? "",
openingBalance: a.openingBalance ?? 0,
notes: a.notes ?? "",
archived: Boolean(a.archived),
balance: balances.get(a.$id) ?? a.openingBalance ?? 0,
scope: (a.scope ?? "company") as "company" | "personal",
}))}
/>
</div>
);
}
@@ -1,228 +0,0 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createCreditCardAction,
updateCreditCardAction,
} from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, CreditCardRow } from "./types";
const NONE = "__none__";
export function CardFormSheet({
open,
onOpenChange,
card,
bankAccounts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
bankAccounts: BankAccountOption[];
}) {
const isEdit = Boolean(card);
const action = isEdit ? updateCreditCardAction : createCreditCardAction;
const [state, formAction, isPending] = useActionState(action, initialCreditCardState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kart güncellendi." : "Kart eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kartı düzenle" : "Yeni kredi kartı"}</SheetTitle>
<SheetDescription>
Hesap kesim ve son ödeme günleri her ay otomatik kullanılır. Ekstreler kart başına manuel girilir.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={card?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input
id="bankName"
name="bankName"
defaultValue={card?.bankName ?? ""}
required
placeholder="Garanti BBVA"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cardName">Kart adı *</Label>
<Input
id="cardName"
name="cardName"
defaultValue={card?.cardName ?? ""}
required
placeholder="Bonus / Maximum / World"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="last4">Son 4 hane</Label>
<Input
id="last4"
name="last4"
defaultValue={card?.last4 ?? ""}
maxLength={4}
inputMode="numeric"
placeholder="1234"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditLimit">Kredi limiti ()</Label>
<Input
id="creditLimit"
name="creditLimit"
type="number"
step="0.01"
min="0"
defaultValue={card?.creditLimit ?? 0}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="statementDay">Hesap kesim günü</Label>
<Input
id="statementDay"
name="statementDay"
type="number"
min="1"
max="28"
defaultValue={card?.statementDay ?? 1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDay">Son ödeme günü</Label>
<Input
id="dueDay"
name="dueDay"
type="number"
min="1"
max="28"
defaultValue={card?.dueDay ?? 10}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="interestRate">Aylık faiz %</Label>
<Input
id="interestRate"
name="interestRate"
type="number"
step="0.01"
min="0"
defaultValue={card?.interestRate ?? 4.25}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
<Select
name="bankAccountId"
defaultValue={card?.bankAccountId || NONE}
disabled={bankAccounts.length === 0}
>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Ekstre ödemeleri seçilen hesaba expense olarak yazılır.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={card?.notes ?? ""}
placeholder="Sadakat puanı, kampanya, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,522 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
Archive,
ArchiveRestore,
Check,
CreditCard as CreditCardIcon,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
archiveCreditCardAction,
deleteCreditCardAction,
deleteStatementAction,
payStatementAction,
} from "@/lib/appwrite/credit-card-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { CardFormSheet } from "./card-form-sheet";
import { StatementFormSheet } from "./statement-form-sheet";
import {
type BankAccountOption,
type CreditCardRow,
STATEMENT_STATUS_COLOR,
STATEMENT_STATUS_LABEL,
type StatementRow,
} from "./types";
type Props = {
cards: CreditCardRow[];
statements: StatementRow[];
bankAccounts: BankAccountOption[];
};
export function CardsClient({ cards, statements, bankAccounts }: Props) {
const [cardFormOpen, setCardFormOpen] = useState(false);
const [editingCard, setEditingCard] = useState<CreditCardRow | null>(null);
const [deletingCard, setDeletingCard] = useState<CreditCardRow | null>(null);
const [stmtFormOpen, setStmtFormOpen] = useState(false);
const [stmtCard, setStmtCard] = useState<CreditCardRow | null>(null);
const [payDialog, setPayDialog] = useState<StatementRow | null>(null);
const [payAmount, setPayAmount] = useState("");
const [deletingStmt, setDeletingStmt] = useState<StatementRow | null>(null);
const [busy, startTransition] = useTransition();
const active = cards.filter((c) => !c.archived);
const archived = cards.filter((c) => c.archived);
const stmtsByCard = new Map<string, StatementRow[]>();
for (const s of statements) {
const arr = stmtsByCard.get(s.cardId) ?? [];
arr.push(s);
stmtsByCard.set(s.cardId, arr);
}
const totalOutstanding = statements
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
const overdueCount = statements.filter((s) => s.status === "overdue").length;
const toggleArchive = (c: CreditCardRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", c.id);
const r = await archiveCreditCardAction(fd);
if (r.ok) toast.success(c.archived ? "Kart geri açıldı." : "Kart arşivlendi.");
else toast.error(r.error ?? "İşlem başarısız.");
});
};
const handleDeleteCard = () => {
if (!deletingCard) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingCard.id);
const r = await deleteCreditCardAction(fd);
if (r.ok) {
toast.success("Kart silindi.");
setDeletingCard(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
const handlePay = () => {
if (!payDialog) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", payDialog.id);
if (payAmount.trim()) fd.set("amount", payAmount);
const r = await payStatementAction(fd);
if (r.ok) {
toast.success("Ödeme kaydedildi.");
setPayDialog(null);
setPayAmount("");
} else {
toast.error(r.error ?? "Ödeme başarısız.");
}
});
};
const handleDeleteStmt = () => {
if (!deletingStmt) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingStmt.id);
const r = await deleteStatementAction(fd);
if (r.ok) {
toast.success("Ekstre silindi.");
setDeletingStmt(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Aktif kart</p>
<p className="mt-1 text-2xl font-semibold">{active.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Bekleyen toplam borç</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalOutstanding)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Vadesi geçmiş ekstre</p>
<p
className={cn(
"mt-1 text-2xl font-semibold",
overdueCount > 0 && "text-red-600 dark:text-red-400",
)}
>
{overdueCount}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setStmtCard(null);
setStmtFormOpen(true);
}}
disabled={cards.length === 0}
>
<Plus className="size-4" />
Yeni ekstre
</Button>
<Button
onClick={() => {
setEditingCard(null);
setCardFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni kart
</Button>
</div>
{cards.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<CreditCardIcon className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi kartı eklenmemiş.</p>
<Button variant="outline" size="sm" onClick={() => setCardFormOpen(true)}>
<Plus className="size-3.5" />
İlk kartı ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{active.map((c) => {
const items = stmtsByCard.get(c.id) ?? [];
const totalDebt = items
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
return (
<Card key={c.id}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<CreditCardIcon className="text-muted-foreground size-4" />
<h3 className="font-semibold">{c.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span>{c.cardName}</span>
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>Limit {formatTRY(c.creditLimit)}</span>
<span>Kesim: ayın {c.statementDay}'i</span>
<span>Vade: ayın {c.dueDay}'i</span>
<span>Aylık faiz: %{c.interestRate}</span>
</div>
{c.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {c.bankAccountLabel}
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-muted-foreground text-xs">Bekleyen</p>
<p className="font-semibold tabular-nums">{formatTRY(totalDebt)}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setStmtCard(c);
setStmtFormOpen(true);
}}
>
<Plus className="size-3.5" />
Ekstre ekle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setEditingCard(c);
setCardFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleArchive(c)}>
<Archive className="size-3.5" />
Arşivle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingCard(c)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{items.length > 0 && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead>Dönem</TableHead>
<TableHead>Son ödeme</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Asgari</TableHead>
<TableHead className="text-right">Ödenen</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => {
const remaining = s.totalDebt - s.paidAmount;
return (
<TableRow key={s.id}>
<TableCell className="font-mono text-sm">{s.period}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.totalDebt)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.minimumPayment)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.paidAmount)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn("border-0", STATEMENT_STATUS_COLOR[s.status])}
>
{STATEMENT_STATUS_LABEL[s.status]}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{remaining > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPayDialog(s);
setPayAmount(remaining.toFixed(2));
}}
>
<Check className="size-3.5" />
Öde
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setDeletingStmt(s)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş kartlar ({archived.length})
</summary>
<div className="mt-4 space-y-3">
{archived.map((c) => (
<Card key={c.id} className="opacity-70">
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="font-medium">
{c.bankName} {c.cardName}{" "}
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</p>
<p className="text-muted-foreground text-xs">Arşivli</p>
</div>
<Button variant="outline" size="sm" onClick={() => toggleArchive(c)}>
<ArchiveRestore className="size-3.5" />
Geri
</Button>
</CardContent>
</Card>
))}
</div>
</details>
)}
</div>
)}
<CardFormSheet
open={cardFormOpen}
onOpenChange={(v) => {
setCardFormOpen(v);
if (!v) setEditingCard(null);
}}
card={editingCard}
bankAccounts={bankAccounts}
/>
<StatementFormSheet
open={stmtFormOpen}
onOpenChange={(v) => {
setStmtFormOpen(v);
if (!v) setStmtCard(null);
}}
card={stmtCard}
cards={cards}
/>
<Dialog open={Boolean(deletingCard)} onOpenChange={(v) => !v && setDeletingCard(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kartı sil</DialogTitle>
<DialogDescription>
<strong>
{deletingCard?.bankName} {deletingCard?.cardName}
</strong>{" "}
ve tüm ekstreleri silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingCard(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteCard} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(payDialog)}
onOpenChange={(v) => {
if (!v) {
setPayDialog(null);
setPayAmount("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstre ödemesi</DialogTitle>
<DialogDescription>
{payDialog && (
<>
<strong>{payDialog.period}</strong> dönemi kalan{" "}
{formatTRY(payDialog.totalDebt - payDialog.paidAmount)}.
<br />
Tutarı boş bırakırsanız tamamı ödenir.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<label className="text-sm font-medium">Ödenen tutar ()</label>
<Input
type="number"
step="0.01"
min="0"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPayDialog(null)} disabled={busy}>
Vazgeç
</Button>
<Button onClick={handlePay} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Ödemeyi kaydet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deletingStmt)} onOpenChange={(v) => !v && setDeletingStmt(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstreyi sil</DialogTitle>
<DialogDescription>
<strong>{deletingStmt?.period}</strong> ekstresi silinecek. Bağlı gider kaydı varsa
o da silinir.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingStmt(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteStmt} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,197 +0,0 @@
"use client";
import { useActionState, useEffect, useMemo } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createStatementAction } from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import type { CreditCardRow } from "./types";
function pad(n: number) {
return String(n).padStart(2, "0");
}
function defaultDates(card?: CreditCardRow | null) {
const now = new Date();
const sd = card?.statementDay ?? 1;
const dd = card?.dueDay ?? 10;
const statement = new Date(now.getFullYear(), now.getMonth(), Math.min(sd, 28));
const due = new Date(now.getFullYear(), now.getMonth(), Math.min(dd, 28));
if (due.getTime() < statement.getTime()) due.setMonth(due.getMonth() + 1);
const period = `${statement.getFullYear()}-${pad(statement.getMonth() + 1)}`;
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return { period, statementDate: ymd(statement), dueDate: ymd(due) };
}
export function StatementFormSheet({
open,
onOpenChange,
card,
cards,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
cards: CreditCardRow[];
}) {
const [state, formAction, isPending] = useActionState(
createStatementAction,
initialCreditCardState,
);
const defaults = useMemo(() => defaultDates(card), [card]);
useEffect(() => {
if (state.ok) {
toast.success("Ekstre kaydedildi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>Yeni ekstre</SheetTitle>
<SheetDescription>
Banka ekstrenizdeki dönem, son ödeme tarihi, toplam borç ve asgari ödeme tutarını girin.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="cardId">Kart *</Label>
<Select name="cardId" defaultValue={card?.id ?? ""}>
<SelectTrigger id="cardId">
<SelectValue placeholder="Kart seçin" />
</SelectTrigger>
<SelectContent>
{cards.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.bankName} {c.cardName} {c.last4 ? `**${c.last4}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.cardId && (
<p className="text-destructive text-xs">{state.fieldErrors.cardId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="period">Dönem (YYYY-AA) *</Label>
<Input
id="period"
name="period"
defaultValue={defaults.period}
pattern="\d{4}-\d{2}"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="statementDate">Hesap kesim *</Label>
<Input
id="statementDate"
name="statementDate"
type="date"
defaultValue={defaults.statementDate}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Son ödeme *</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={defaults.dueDate}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="totalDebt">Toplam borç () *</Label>
<Input
id="totalDebt"
name="totalDebt"
type="number"
step="0.01"
min="0"
required
placeholder="0.00"
/>
{state.fieldErrors?.totalDebt && (
<p className="text-destructive text-xs">{state.fieldErrors.totalDebt}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="minimumPayment">Asgari ödeme ()</Label>
<Input
id="minimumPayment"
name="minimumPayment"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} placeholder="Önemli ekstre notları" />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,44 +0,0 @@
export type CreditCardRow = {
id: string;
bankName: string;
cardName: string;
last4: string;
creditLimit: number;
statementDay: number;
dueDay: number;
interestRate: number;
bankAccountId: string;
bankAccountLabel: string;
archived: boolean;
notes: string;
scope: "company" | "personal";
};
export type StatementRow = {
id: string;
cardId: string;
period: string; // YYYY-MM
statementDate: string;
dueDate: string;
totalDebt: number;
minimumPayment: number;
paidAmount: number;
status: "pending" | "partial" | "paid" | "overdue";
notes: string;
};
export type BankAccountOption = { id: string; label: string };
export const STATEMENT_STATUS_LABEL: Record<StatementRow["status"], string> = {
pending: "Bekliyor",
partial: "Kısmi ödendi",
paid: "Ödendi",
overdue: "Gecikti",
};
export const STATEMENT_STATUS_COLOR: Record<StatementRow["status"], string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
@@ -1,81 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import {
listCreditCards,
listStatements,
} from "@/lib/appwrite/credit-card-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CardsClient } from "./components/cards-client";
export const metadata: Metadata = {
title: "İşletmem — Kredi kartları",
};
export default async function CardsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [cards, statements, bankAccounts] = await Promise.all([
listCreditCards(ctx.tenantId, ctx.user.id),
listStatements(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
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">Kredi kartları</h1>
<p className="text-muted-foreground text-sm">
Kartlarınızı ve aylık ekstrelerinizi takip edin. Ekstre ödendiğinde otomatik gider kaydı oluşur.
</p>
</div>
<CardsClient
cards={cards.map((c) => ({
id: c.$id,
bankName: c.bankName,
cardName: c.cardName,
last4: c.last4 ?? "",
creditLimit: c.creditLimit ?? 0,
statementDay: c.statementDay ?? 1,
dueDay: c.dueDay ?? 10,
interestRate: c.interestRate ?? 4.25,
bankAccountId: c.bankAccountId ?? "",
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
archived: Boolean(c.archived),
notes: c.notes ?? "",
scope: (c.scope ?? "company") as "company" | "personal",
}))}
statements={statements.map((s) => ({
id: s.$id,
cardId: s.cardId,
period: s.period,
statementDate: s.statementDate,
dueDate: s.dueDate,
totalDebt: s.totalDebt,
minimumPayment: s.minimumPayment ?? 0,
paidAmount: s.paidAmount ?? 0,
status: s.status ?? "pending",
notes: s.notes ?? "",
}))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
@@ -1,436 +0,0 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import {
ArrowDownCircle,
ArrowUpCircle,
CircleAlert,
CircleDollarSign,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteFinanceEntryAction } from "@/lib/appwrite/finance-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { FinanceFormSheet } from "./finance-form-sheet";
import {
type BankAccountOption,
type Customer,
type FinanceRow,
type FinanceType,
PAYMENT_METHOD_LABEL,
TYPE_COLOR,
TYPE_LABEL,
} from "./types";
type Props = {
entries: FinanceRow[];
customers: Customer[];
bankAccounts: BankAccountOption[];
};
function StatCard({
label,
amount,
icon: Icon,
tone,
}: {
label: string;
amount: number;
icon: typeof Wallet;
tone: "income" | "expense" | "receivable" | "debt" | "net";
}) {
const toneClass = {
income: "text-emerald-600 dark:text-emerald-400",
expense: "text-red-600 dark:text-red-400",
receivable: "text-blue-600 dark:text-blue-400",
debt: "text-amber-600 dark:text-amber-400",
net: amount >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
}[tone];
return (
<Card>
<CardContent className="flex items-start justify-between p-4">
<div>
<p className="text-muted-foreground text-xs">{label}</p>
<p className={cn("mt-1 text-xl font-semibold", toneClass)}>{formatTRY(amount)}</p>
</div>
<Icon className={cn("size-5", toneClass)} />
</CardContent>
</Card>
);
}
export function FinanceClient({ entries, customers, bankAccounts }: Props) {
const [tab, setTab] = useState<FinanceType | "all">("all");
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FinanceRow | null>(null);
const [defaultType, setDefaultType] = useState<FinanceType>("income");
const [deleting, setDeleting] = useState<FinanceRow | null>(null);
const [busy, startTransition] = useTransition();
const stats = useMemo(() => {
let income = 0,
expense = 0,
receivable = 0,
debt = 0;
for (const e of entries) {
if (e.type === "income") income += e.amount;
else if (e.type === "expense") expense += e.amount;
else if (e.type === "receivable") receivable += e.amount;
else if (e.type === "debt") debt += e.amount;
}
return { income, expense, receivable, debt, net: income - expense };
}, [entries]);
const filtered = useMemo(
() => (tab === "all" ? entries : entries.filter((e) => e.type === tab)),
[entries, tab],
);
const columns = useMemo<ColumnDef<FinanceRow>[]>(
() => [
{
accessorKey: "type",
header: "Tür",
cell: ({ row }) => (
<Badge variant="outline" className={cn("border-0", TYPE_COLOR[row.original.type])}>
{TYPE_LABEL[row.original.type]}
</Badge>
),
},
{
accessorKey: "amount",
header: "Tutar",
cell: ({ row }) => {
const sign =
row.original.type === "income" || row.original.type === "receivable" ? "+" : "";
return (
<span className="font-medium tabular-nums">
{sign} {formatTRY(row.original.amount)}
</span>
);
},
},
{
accessorKey: "date",
header: "Tarih",
cell: ({ row }) => (
<span className="text-muted-foreground">{formatDate(row.original.date)}</span>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) =>
row.original.customerName ? (
<span>{row.original.customerName}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "paymentMethod",
header: "Ödeme",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{PAYMENT_METHOD_LABEL[row.original.paymentMethod]}
</span>
),
},
{
accessorKey: "description",
header: "Açıklama",
cell: ({ row }) => (
<div className="flex max-w-[300px] items-center gap-2">
<span className="text-muted-foreground line-clamp-1 text-sm">
{row.original.description || "—"}
</span>
{row.original.invoiceId && (
<Badge variant="outline" className="shrink-0 text-[10px]">
Faturadan
</Badge>
)}
</div>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditing(row.original);
setFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: filtered,
columns,
state: { globalFilter: search, sorting },
onGlobalFilterChange: setSearch,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.description, row.original.customerName, row.original.amount.toString()]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteFinanceEntryAction(fd);
if (result.ok) {
toast.success("Kayıt silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
const openCreate = (type: FinanceType) => {
setEditing(null);
setDefaultType(type);
setFormOpen(true);
};
return (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
label="Gelir"
amount={stats.income}
icon={ArrowUpCircle}
tone="income"
/>
<StatCard
label="Gider"
amount={stats.expense}
icon={ArrowDownCircle}
tone="expense"
/>
<StatCard label="Net" amount={stats.net} icon={CircleDollarSign} tone="net" />
<StatCard
label="Alacaklar"
amount={stats.receivable}
icon={Wallet}
tone="receivable"
/>
<StatCard label="Borçlar" amount={stats.debt} icon={CircleAlert} tone="debt" />
</div>
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tümü</SelectItem>
<SelectItem value="income">Gelir</SelectItem>
<SelectItem value="expense">Gider</SelectItem>
<SelectItem value="receivable">Alacaklar</SelectItem>
<SelectItem value="debt">Borçlar</SelectItem>
</SelectContent>
</Select>
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Açıklama, müşteri, tutar..."
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => openCreate("income")}>
<Plus className="size-3.5" />
Gelir
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("expense")}>
<Plus className="size-3.5" />
Gider
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("receivable")}>
<Plus className="size-3.5" />
Alacak
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("debt")}>
<Plus className="size-3.5" />
Borç
</Button>
</div>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<p className="text-muted-foreground text-sm">Kayıt yok.</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<FinanceFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
entry={editing}
defaultType={defaultType}
customers={customers}
bankAccounts={bankAccounts}
onRequestDelete={(e) => {
setFormOpen(false);
setDeleting(e);
}}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kaydı sil</DialogTitle>
<DialogDescription>
{deleting && (
<>
<strong>{TYPE_LABEL[deleting.type]}</strong> {formatTRY(deleting.amount)} (
{formatDate(deleting.date)}) silinecek.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,275 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createFinanceEntryAction,
updateFinanceEntryAction,
} from "@/lib/appwrite/finance-actions";
import { initialFinanceState } from "@/lib/appwrite/finance-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, Customer, FinanceRow, FinanceType } from "./types";
const NONE = "__none__";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
entry?: FinanceRow | null;
defaultType?: FinanceType;
customers: Customer[];
bankAccounts: BankAccountOption[];
onRequestDelete?: (entry: FinanceRow) => void;
};
function isoToDate(iso: string): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export function FinanceFormSheet({
open,
onOpenChange,
entry,
defaultType = "income",
customers,
bankAccounts,
onRequestDelete,
}: Props) {
const isEdit = Boolean(entry);
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const today = new Date().toISOString().slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kaydı düzenle" : "Yeni kayıt"}</SheetTitle>
<SheetDescription>
Gelir, gider, borç veya alacak girişi. Borç = ödeyeceğiniz, Alacak = tahsil edeceğiniz.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
["customerId", "paymentMethod", "bankAccountId"].forEach((k) => {
if (fd.get(k) === NONE) fd.set(k, "");
});
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && entry && <input type="hidden" name="id" value={entry.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle
defaultValue={(entry as { scope?: "company" | "personal" } | null | undefined)?.scope ?? "company"}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="type">Tür *</Label>
<Select name="type" defaultValue={entry?.type ?? defaultType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="income">Gelir</SelectItem>
<SelectItem value="expense">Gider</SelectItem>
<SelectItem value="receivable">Alacak</SelectItem>
<SelectItem value="debt">Borç</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="amount">Tutar () *</Label>
<Input
id="amount"
name="amount"
type="number"
step="0.01"
min="0.01"
defaultValue={entry?.amount ?? ""}
placeholder="0.00"
required
/>
{state.fieldErrors?.amount && (
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="date">Tarih *</Label>
<Input
id="date"
name="date"
type="date"
defaultValue={isoToDate(entry?.date ?? today)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentMethod">Ödeme yöntemi</Label>
<Select
name="paymentMethod"
defaultValue={entry?.paymentMethod || NONE}
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Belirtilmemiş" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Belirtilmemiş</SelectItem>
<SelectItem value="cash">Nakit</SelectItem>
<SelectItem value="transfer">Havale / EFT</SelectItem>
<SelectItem value="card">Kart</SelectItem>
<SelectItem value="check">Çek</SelectItem>
<SelectItem value="other">Diğer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
<Select name="customerId" defaultValue={entry?.customerId || NONE}>
<SelectTrigger id="customerId">
<SelectValue placeholder="Yok" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Banka hesabı</Label>
<Select
name="bankAccountId"
defaultValue={entry?.bankAccountId || NONE}
disabled={bankAccounts.length === 0}
>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={entry?.description ?? ""}
placeholder="Hangi kalem, hangi fatura, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2">
<div>
{isEdit && entry && onRequestDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRequestDelete(entry)}
disabled={isPending}
>
<Trash2 className="size-3.5" />
Sil
</Button>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</div>
</SheetFooter>
</form>
</SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet>
);
}
@@ -1,42 +0,0 @@
export type FinanceType = "income" | "expense" | "debt" | "receivable";
export type PaymentMethod = "cash" | "transfer" | "card" | "check" | "other" | "";
export type FinanceRow = {
id: string;
type: FinanceType;
amount: number;
date: string;
description: string;
customerId: string;
customerName: string;
paymentMethod: PaymentMethod;
invoiceId: string;
bankAccountId: string;
bankAccountLabel: string;
};
export type Customer = { id: string; name: string };
export type BankAccountOption = { id: string; label: string };
export const TYPE_LABEL: Record<FinanceType, string> = {
income: "Gelir",
expense: "Gider",
debt: "Borç",
receivable: "Alacak",
};
export const TYPE_COLOR: Record<FinanceType, string> = {
income: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
expense: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
debt: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
receivable: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
};
export const PAYMENT_METHOD_LABEL: Record<PaymentMethod, string> = {
cash: "Nakit",
transfer: "Havale / EFT",
card: "Kart",
check: "Çek",
other: "Diğer",
"": "—",
};
@@ -1,257 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createLoanAction } from "@/lib/appwrite/loan-actions";
import { initialLoanState } from "@/lib/appwrite/loan-types";
import { formatTRY } from "@/lib/format";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption } from "./types";
const NONE = "__none__";
function computeMonthly(principal: number, ratePct: number, n: number): number {
if (!principal || !n) return 0;
const r = ratePct / 100;
if (r === 0) return Number((principal / n).toFixed(2));
const factor = Math.pow(1 + r, n);
return Number(((principal * r * factor) / (factor - 1)).toFixed(2));
}
export function LoanFormSheet({
open,
onOpenChange,
bankAccounts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
bankAccounts: BankAccountOption[];
}) {
const [state, formAction, isPending] = useActionState(createLoanAction, initialLoanState);
const [principal, setPrincipal] = useState(0);
const [rate, setRate] = useState(2.5);
const [term, setTerm] = useState(24);
useEffect(() => {
if (state.ok) {
toast.success("Kredi kaydedildi, taksitler oluşturuldu.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const monthly = computeMonthly(principal, rate, term);
const total = monthly * term;
const today = new Date().toISOString().slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>Yeni kredi</SheetTitle>
<SheetDescription>
Kaydedince {term || 0} adet taksit otomatik hesaplanır ve eklenir.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
formAction(fd);
}}
className="flex flex-1 flex-col"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input id="bankName" name="bankName" required placeholder="Garanti BBVA" />
{state.fieldErrors?.bankName && (
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="loanType">Tür</Label>
<Select name="loanType" defaultValue="consumer">
<SelectTrigger id="loanType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="consumer">İhtiyaç</SelectItem>
<SelectItem value="vehicle">Taşıt</SelectItem>
<SelectItem value="housing">Konut</SelectItem>
<SelectItem value="commercial">Ticari</SelectItem>
<SelectItem value="kmh">KMH</SelectItem>
<SelectItem value="other">Diğer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="loanName">Kredi adı *</Label>
<Input id="loanName" name="loanName" required placeholder="Örn. Ofis kredisi" />
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
<Select name="bankAccountId" defaultValue={NONE} disabled={bankAccounts.length === 0}>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Taksit ödemeleri seçilen hesaba expense olarak yazılır.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="principal">Anapara () *</Label>
<Input
id="principal"
name="principal"
type="number"
step="0.01"
min="0.01"
required
value={principal || ""}
onChange={(e) => setPrincipal(Number(e.target.value) || 0)}
/>
{state.fieldErrors?.principal && (
<p className="text-destructive text-xs">{state.fieldErrors.principal}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="interestRate">Aylık faiz %</Label>
<Input
id="interestRate"
name="interestRate"
type="number"
step="0.01"
min="0"
max="100"
required
value={rate}
onChange={(e) => setRate(Number(e.target.value) || 0)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="termMonths">Vade (ay) *</Label>
<Input
id="termMonths"
name="termMonths"
type="number"
min="1"
max="480"
required
value={term}
onChange={(e) => setTerm(Number(e.target.value) || 0)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="startDate">Başlangıç *</Label>
<Input
id="startDate"
name="startDate"
type="date"
defaultValue={today}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentDay">Ödeme günü (1-28)</Label>
<Input
id="paymentDay"
name="paymentDay"
type="number"
min="1"
max="28"
defaultValue={1}
/>
</div>
</div>
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Aylık taksit</span>
<span className="font-medium tabular-nums">{formatTRY(monthly)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Toplam ödeme</span>
<span className="font-medium tabular-nums">{formatTRY(total)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Toplam faiz</span>
<span className="tabular-nums">{formatTRY(Math.max(0, total - principal))}</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} placeholder="Sözleşme no, kefiller, vb." />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Oluşturuluyor...
</>
) : (
<>
<Save className="size-4" />
Krediyi kaydet
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,357 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
Banknote,
Check,
ChevronDown,
ChevronUp,
Loader2,
Plus,
RotateCcw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
deleteLoanAction,
payInstallmentAction,
unpayInstallmentAction,
} from "@/lib/appwrite/loan-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { LoanFormSheet } from "./loan-form-sheet";
import {
type BankAccountOption,
type InstallmentRow,
LOAN_STATUS_LABEL,
LOAN_TYPE_LABEL,
type LoanRow,
} from "./types";
type Props = {
loans: LoanRow[];
installments: InstallmentRow[];
bankAccounts: BankAccountOption[];
};
export function LoansClient({ loans, installments, bankAccounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [deleting, setDeleting] = useState<LoanRow | null>(null);
const [busy, startTransition] = useTransition();
const totalPrincipal = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + l.principal, 0);
const totalRemaining = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + (l.totalAmount - l.paidAmount), 0);
const installmentsByLoan = new Map<string, InstallmentRow[]>();
for (const i of installments) {
const arr = installmentsByLoan.get(i.loanId) ?? [];
arr.push(i);
installmentsByLoan.set(i.loanId, arr);
}
const togglePay = (inst: InstallmentRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", inst.id);
const result = inst.paid ? await unpayInstallmentAction(fd) : await payInstallmentAction(fd);
if (result.ok) {
toast.success(inst.paid ? "Taksit ödenmedi olarak işaretlendi." : "Taksit ödendi olarak işaretlendi.");
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteLoanAction(fd);
if (result.ok) {
toast.success("Kredi silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Aktif kredi sayısı</p>
<p className="mt-1 text-2xl font-semibold">
{loans.filter((l) => l.status === "active").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam çekilen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatTRY(totalPrincipal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Kalan ödeme</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalRemaining)}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={() => setFormOpen(true)}>
<Plus className="size-4" />
Yeni kredi
</Button>
</div>
{loans.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Banknote className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi tanımlanmamış.</p>
<Button variant="outline" size="sm" onClick={() => setFormOpen(true)}>
<Plus className="size-3.5" />
İlk krediyi ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const isOpen = expanded === loan.id;
const items = installmentsByLoan.get(loan.id) ?? [];
const progressPct =
loan.totalAmount > 0 ? (loan.paidAmount / loan.totalAmount) * 100 : 0;
return (
<Card key={loan.id}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{loan.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span className="text-sm">{loan.loanName}</span>
<Badge variant="outline" className="text-[10px]">
{LOAN_TYPE_LABEL[loan.loanType]}
</Badge>
<Badge
variant="outline"
className={cn(
"text-[10px]",
loan.status === "active"
? "border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300"
: loan.status === "closed"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300",
)}
>
{LOAN_STATUS_LABEL[loan.status]}
</Badge>
</div>
{loan.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {loan.bankAccountLabel}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(isOpen ? null : loan.id)}
>
{isOpen ? (
<>
<ChevronUp className="size-3.5" />
Kapat
</>
) : (
<>
<ChevronDown className="size-3.5" />
Taksitler ({items.length})
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(loan)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-2 md:grid-cols-4">
<Stat label="Anapara" value={formatTRY(loan.principal)} />
<Stat label="Aylık taksit" value={formatTRY(loan.monthlyPayment)} />
<Stat label="Aylık faiz" value={`%${loan.interestRate}`} />
<Stat
label="Sonraki ödeme"
value={loan.nextDue ? formatDate(loan.nextDue) : "—"}
/>
</div>
<div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
{loan.remainingCount === 0
? "Tüm taksitler ödendi"
: `${loan.remainingCount} taksit kaldı`}
</span>
<span>
{formatTRY(loan.paidAmount)} / {formatTRY(loan.totalAmount)}
</span>
</div>
<div className="bg-muted mt-1 h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${Math.min(100, progressPct)}%` }}
/>
</div>
</div>
{isOpen && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Anapara</TableHead>
<TableHead className="text-right">Faiz</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const overdue =
!it.paid && new Date(it.dueDate) < new Date();
return (
<TableRow
key={it.id}
className={cn(it.paid && "opacity-60")}
>
<TableCell className="font-mono">{it.installmentNo}</TableCell>
<TableCell
className={cn(
"text-muted-foreground text-sm",
overdue && "text-destructive font-medium",
)}
>
{formatDate(it.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.principalPart)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.interestPart)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.amount)}
</TableCell>
<TableCell className="text-right">
<Button
variant={it.paid ? "ghost" : "outline"}
size="sm"
disabled={busy}
onClick={() => togglePay(it)}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : it.paid ? (
<>
<RotateCcw className="size-3.5" />
Geri al
</>
) : (
<>
<Check className="size-3.5" />
Ödendi
</>
)}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
<LoanFormSheet open={formOpen} onOpenChange={setFormOpen} bankAccounts={bankAccounts} />
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Krediyi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.loanName}</strong> ve tüm taksitleri silinecek.
Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">{label}</p>
<p className="mt-0.5 text-sm font-medium tabular-nums">{value}</p>
</div>
);
}
@@ -1,50 +0,0 @@
export type LoanRow = {
id: string;
bankName: string;
loanName: string;
loanType: "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
principal: number;
interestRate: number;
termMonths: number;
monthlyPayment: number;
startDate: string;
paymentDay: number;
status: "active" | "closed" | "defaulted";
bankAccountId: string;
bankAccountLabel: string;
notes: string;
totalAmount: number;
paidAmount: number;
remainingCount: number;
nextDue: string | null;
scope: "company" | "personal";
};
export type InstallmentRow = {
id: string;
loanId: string;
installmentNo: number;
dueDate: string;
amount: number;
principalPart: number;
interestPart: number;
paid: boolean;
paidAt: string;
};
export type BankAccountOption = { id: string; label: string };
export const LOAN_TYPE_LABEL: Record<LoanRow["loanType"], string> = {
consumer: "İhtiyaç",
vehicle: "Taşıt",
housing: "Konut",
commercial: "Ticari",
kmh: "KMH",
other: "Diğer",
};
export const LOAN_STATUS_LABEL: Record<LoanRow["status"], string> = {
active: "Aktif",
closed: "Kapalı",
defaulted: "Temerrüt",
};
-116
View File
@@ -1,116 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import { listAllInstallments, listLoans } from "@/lib/appwrite/loan-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LoansClient } from "./components/loans-client";
export const metadata: Metadata = {
title: "İşletmem — Krediler",
};
export default async function LoansPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [loans, installments, bankAccounts] = await Promise.all([
listLoans(ctx.tenantId, ctx.user.id),
listAllInstallments(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
// Aggregate installment metrics per loan
const byLoan = new Map<
string,
{ totalAmount: number; paidAmount: number; nextDue: string | null; remainingCount: number }
>();
for (const inst of installments) {
const cur = byLoan.get(inst.loanId) ?? {
totalAmount: 0,
paidAmount: 0,
nextDue: null,
remainingCount: 0,
};
cur.totalAmount += inst.amount ?? 0;
if (inst.paid) {
cur.paidAmount += inst.amount ?? 0;
} else {
cur.remainingCount += 1;
if (!cur.nextDue || new Date(inst.dueDate).getTime() < new Date(cur.nextDue).getTime()) {
cur.nextDue = inst.dueDate;
}
}
byLoan.set(inst.loanId, cur);
}
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">Krediler</h1>
<p className="text-muted-foreground text-sm">
Banka kredilerinizi ve taksit planlarını takip edin. Taksit ödendiğinde otomatik gider
kaydı oluşur.
</p>
</div>
<LoansClient
loans={loans.map((l) => {
const m = byLoan.get(l.$id) ?? {
totalAmount: 0,
paidAmount: 0,
nextDue: null,
remainingCount: 0,
};
return {
id: l.$id,
bankName: l.bankName,
loanName: l.loanName,
loanType: l.loanType ?? "consumer",
principal: l.principal,
interestRate: l.interestRate,
termMonths: l.termMonths,
monthlyPayment: l.monthlyPayment ?? 0,
startDate: l.startDate,
paymentDay: l.paymentDay ?? 1,
status: l.status ?? "active",
bankAccountId: l.bankAccountId ?? "",
bankAccountLabel: l.bankAccountId ? bankMap.get(l.bankAccountId) ?? "" : "",
notes: l.notes ?? "",
totalAmount: m.totalAmount,
paidAmount: m.paidAmount,
remainingCount: m.remainingCount,
nextDue: m.nextDue,
scope: (l.scope ?? "company") as "company" | "personal",
};
})}
installments={installments.map((i) => ({
id: i.$id,
loanId: i.loanId,
installmentNo: i.installmentNo,
dueDate: i.dueDate,
amount: i.amount,
principalPart: i.principalPart ?? 0,
interestPart: i.interestPart ?? 0,
paid: Boolean(i.paid),
paidAt: i.paidAt ?? "",
}))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
-67
View File
@@ -1,67 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listFinanceEntries } from "@/lib/appwrite/finance-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { FinanceClient } from "./components/finance-client";
export const metadata: Metadata = {
title: "İşletmem — Gelir / Gider",
};
export default async function FinancePage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [entries, customers, bankAccounts] = await Promise.all([
listFinanceEntries(ctx.tenantId, ctx.user.id),
listCustomers(ctx.tenantId),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
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">Gelir / Gider</h1>
<p className="text-muted-foreground text-sm">
Nakit hareketleri, borç ve alacaklarınızı tek yerden takip edin.
</p>
</div>
<FinanceClient
entries={entries.map((e) => ({
id: e.$id,
type: e.type,
amount: e.amount,
date: e.date,
description: e.description ?? "",
customerId: e.customerId ?? "",
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
paymentMethod: e.paymentMethod ?? "",
invoiceId: e.invoiceId ?? "",
bankAccountId: e.bankAccountId ?? "",
bankAccountLabel: e.bankAccountId ? bankMap.get(e.bankAccountId) ?? "" : "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
@@ -1,542 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
Banknote,
Building2,
CircleDollarSign,
CreditCard,
Crown,
ExternalLink,
Receipt,
Wallet,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { FinancialReport, ReportPeriod } from "@/lib/appwrite/finance-report-queries";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
const PERIOD_LABEL: Record<ReportPeriod, string> = {
month: "Bu ay",
quarter: "Bu çeyrek",
year: "Bu yıl",
all: "Tüm zamanlar",
};
const STATUS_LABEL: Record<"pending" | "partial" | "overdue", string> = {
pending: "Bekliyor",
partial: "Kısmi",
overdue: "Gecikti",
};
const STATUS_COLOR: Record<"pending" | "partial" | "overdue", string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
export function ReportClient({ data }: { data: FinancialReport }) {
const router = useRouter();
const setPeriod = (p: ReportPeriod) => {
const params = new URLSearchParams();
if (p !== "month") params.set("period", p);
router.push(`/finance/reports${params.size ? `?${params}` : ""}`);
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Select value={data.period} onValueChange={(v) => setPeriod(v as ReportPeriod)}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">{PERIOD_LABEL.month}</SelectItem>
<SelectItem value="quarter">{PERIOD_LABEL.quarter}</SelectItem>
<SelectItem value="year">{PERIOD_LABEL.year}</SelectItem>
<SelectItem value="all">{PERIOD_LABEL.all}</SelectItem>
</SelectContent>
</Select>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-4">
<KpiCard
label="Nakit pozisyonu"
value={formatTRY(data.kpi.cashPosition)}
tone={data.kpi.cashPosition >= 0 ? "positive" : "negative"}
icon={CircleDollarSign}
subtitle="Banka + alacaklar borçlar"
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} geliri`}
value={formatTRY(data.kpi.income)}
tone="positive"
icon={ArrowUpRight}
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} gideri`}
value={formatTRY(data.kpi.expense)}
tone="negative"
icon={ArrowDownRight}
/>
<KpiCard
label="Net"
value={formatTRY(data.kpi.net)}
tone={data.kpi.net >= 0 ? "positive" : "negative"}
icon={Wallet}
/>
</div>
{/* Cash composition */}
<Card>
<CardHeader>
<CardTitle>Nakit pozisyonu detayı</CardTitle>
<CardDescription>
Bugünkü gerçek nakit + tahsil edilebilir ödenecek borçlar
</CardDescription>
</CardHeader>
<CardContent>
<CompositionRow
icon={Building2}
label="Banka hesapları"
sign="+"
amount={data.composition.bankBalances}
href="/finance/banks"
/>
<CompositionRow
icon={Receipt}
label="Bekleyen tahsilatlar"
sign="+"
amount={data.composition.receivables}
href="/invoices"
/>
<CompositionRow
icon={Banknote}
label="Kredi kalan ödemeler"
sign=""
amount={data.composition.loanRemaining}
href="/finance/loans"
/>
<CompositionRow
icon={CreditCard}
label="Kart ekstre borçları"
sign=""
amount={data.composition.cardOutstanding}
href="/finance/cards"
/>
<div className="mt-3 flex items-center justify-between border-t pt-3">
<span className="text-sm font-semibold">Net pozisyon</span>
<span
className={cn(
"text-lg font-semibold tabular-nums",
data.kpi.cashPosition >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400",
)}
>
{formatTRY(data.kpi.cashPosition)}
</span>
</div>
</CardContent>
</Card>
{/* Trend chart */}
<TrendChartLazy data={data.trend} />
{/* Top customers + Expense breakdown */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown className="size-4" />
En çok ciro yapan müşteriler
</CardTitle>
<CardDescription>
{PERIOD_LABEL[data.period]} ödenmiş faturalara göre
</CardDescription>
</CardHeader>
<CardContent>
{data.topCustomers.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bu dönemde ödenmiş fatura yok.
</p>
) : (
<ul className="space-y-3">
{data.topCustomers.map((c, i) => {
const max = data.topCustomers[0]?.total ?? 1;
const w = (c.total / max) * 100;
return (
<li key={c.name + i} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm">
<span className="text-muted-foreground mr-2 tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
{c.name}
</span>
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${w}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gider dağılımı</CardTitle>
<CardDescription>{PERIOD_LABEL[data.period]} kaynak bazında</CardDescription>
</CardHeader>
<CardContent>
<ExpenseRow
label="Kredi taksit ödemeleri"
amount={data.expenseBreakdown.loans}
total={data.kpi.expense}
color="bg-amber-500"
/>
<ExpenseRow
label="Kredi kartı ödemeleri"
amount={data.expenseBreakdown.cards}
total={data.kpi.expense}
color="bg-violet-500"
/>
<ExpenseRow
label="Diğer (manuel) gider"
amount={data.expenseBreakdown.other}
total={data.kpi.expense}
color="bg-red-500"
/>
{data.kpi.expense === 0 && (
<p className="text-muted-foreground py-2 text-center text-sm">
Bu dönemde gider yok.
</p>
)}
</CardContent>
</Card>
</div>
{/* Loans + Cards summary */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Banknote className="size-4" />
Aktif krediler
</CardTitle>
<CardDescription>Kalan ödeme tutarına göre</CardDescription>
</div>
<Link
href="/finance/loans"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.loans.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Aktif kredi yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kredi</TableHead>
<TableHead className="text-right">Aylık</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Sonraki</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.loans.map((l) => (
<TableRow key={l.id}>
<TableCell>
<span className="block font-medium">{l.bankName}</span>
<span className="text-muted-foreground text-xs">{l.loanName}</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(l.monthlyPayment)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(l.remaining)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{l.nextDue ? formatDate(l.nextDue) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<CreditCard className="size-4" />
Kart ekstreleri
</CardTitle>
<CardDescription>Bekleyen ve gecikmiş ödemeler</CardDescription>
</div>
<Link
href="/finance/cards"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.cardStatements.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Açık ekstre yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kart</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cardStatements.map((s) => (
<TableRow key={s.id}>
<TableCell>
<span className="block text-sm font-medium">{s.cardLabel}</span>
<span className="text-muted-foreground font-mono text-xs">
{s.period}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(s.remaining)}
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("border-0", STATUS_COLOR[s.status])}>
{STATUS_LABEL[s.status]}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* Outstanding invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-4" />
Bekleyen faturalar
</CardTitle>
<CardDescription>
Tahsil edilmesi gereken vadesi geçmiş olanlar üstte
</CardDescription>
</div>
<Link
href="/invoices"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.outstandingInvoices.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bekleyen fatura yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Numara</TableHead>
<TableHead>Müşteri</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Tutar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.outstandingInvoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>
<Link
href={`/invoices/${inv.id}`}
className="hover:text-primary inline-flex items-center gap-1 font-mono text-sm"
>
{inv.number}
</Link>
</TableCell>
<TableCell>{inv.customerName}</TableCell>
<TableCell
className={cn(
"text-sm",
inv.overdue
? "text-destructive flex items-center gap-1 font-medium"
: "text-muted-foreground",
)}
>
{inv.overdue && <AlertCircle className="size-3" />}
{formatDate(inv.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(inv.total)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function KpiCard({
label,
value,
tone,
icon: Icon,
subtitle,
}: {
label: string;
value: string;
tone: "positive" | "negative" | "neutral";
icon: typeof Wallet;
subtitle?: string;
}) {
const cls = {
positive: "text-emerald-600 dark:text-emerald-400",
negative: "text-red-600 dark:text-red-400",
neutral: "text-muted-foreground",
}[tone];
return (
<Card>
<CardContent className="flex items-start justify-between p-5">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide">{label}</p>
<p className={cn("mt-2 text-2xl font-semibold tabular-nums", cls)}>{value}</p>
{subtitle && <p className="text-muted-foreground mt-1 text-xs">{subtitle}</p>}
</div>
<Icon className={cn("size-5", cls)} />
</CardContent>
</Card>
);
}
function CompositionRow({
icon: Icon,
label,
sign,
amount,
href,
}: {
icon: typeof Building2;
label: string;
sign: "+" | "";
amount: number;
href?: string;
}) {
const positive = sign === "+";
const content = (
<div className="flex items-center justify-between border-b py-2 last:border-b-0">
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground size-4" />
<span className="text-sm">{label}</span>
</div>
<span
className={cn(
"font-medium tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{sign} {formatTRY(amount)}
</span>
</div>
);
if (href) {
return (
<Link href={href} className="hover:bg-muted/30 block rounded -mx-2 px-2 transition-colors">
{content}
</Link>
);
}
return content;
}
function ExpenseRow({
label,
amount,
total,
color,
}: {
label: string;
amount: number;
total: number;
color: string;
}) {
const pct = total > 0 ? (amount / total) * 100 : 0;
return (
<div className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between text-sm">
<span>{label}</span>
<span className="tabular-nums">
{formatTRY(amount)}{" "}
<span className="text-muted-foreground text-xs">({pct.toFixed(1)}%)</span>
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div className={cn("h-full", color)} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
// Pull TrendChart in via dynamic import only on client. Recharts is heavy.
import dynamic from "next/dynamic";
const TrendChartLazy = dynamic(
() => import("./trend-chart").then((m) => ({ default: m.TrendChart })),
{ ssr: false },
);
@@ -1,89 +0,0 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
type Point = { month: string; income: number; expense: number; net: number };
export function TrendChart({ data }: { data: Point[] }) {
return (
<Card className="@container">
<CardHeader>
<CardTitle>12 aylık trend</CardTitle>
<CardDescription>Gelir, gider ve net kâr</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="rIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="rExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v))}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: unknown, name) => [
formatTRY(Number(value) || 0),
name === "income" ? "Gelir" : name === "expense" ? "Gider" : "Net",
]}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(v) => (v === "income" ? "Gelir" : v === "expense" ? "Gider" : "Net")}
/>
<Area
type="monotone"
dataKey="income"
stroke="#10b981"
strokeWidth={2}
fill="url(#rIncome)"
/>
<Area
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
fill="url(#rExpense)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -1,49 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
getFinancialReport,
type ReportPeriod,
} from "@/lib/appwrite/finance-report-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { ReportClient } from "./components/report-client";
export const metadata: Metadata = {
title: "İşletmem — Finansal rapor",
};
const ALLOWED: ReportPeriod[] = ["month", "quarter", "year", "all"];
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ period?: string }>;
}) {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const sp = await searchParams;
const period: ReportPeriod = (ALLOWED as string[]).includes(sp.period ?? "")
? (sp.period as ReportPeriod)
: "month";
const data = await getFinancialReport(ctx.tenantId, period);
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">Finansal rapor</h1>
<p className="text-muted-foreground text-sm">
İşletmenizin nakit pozisyonu, gelir/gider performansı ve borç yükünün tek bakışta özeti.
</p>
</div>
<ReportClient data={data} />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold capitalize">investors</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
@@ -1,95 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Pencil, Printer, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
import { InvoiceFormSheet } from "../../components/invoice-form-sheet";
import type { Customer, InvoiceRow } from "../../components/types";
type Props = { invoice: InvoiceRow; customers: Customer[] };
export function InvoiceHeaderActions({ invoice, customers }: Props) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [busy, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", invoice.id);
const result = await deleteInvoiceAction(fd);
if (result.ok) {
toast.success("Fatura silindi.");
router.push("/invoices");
} else {
toast.error(result.error ?? "Silme başarısız.");
setDeleting(false);
}
});
};
return (
<>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="size-3.5" />
Yazdır
</Button>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="size-3.5" />
Düzenle
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(true)}
>
<Trash2 className="size-3.5" />
Sil
</Button>
</div>
<InvoiceFormSheet
open={editOpen}
onOpenChange={setEditOpen}
invoice={invoice}
customers={customers}
/>
<Dialog open={deleting} onOpenChange={setDeleting}>
<DialogContent>
<DialogHeader>
<DialogTitle>Faturayı sil</DialogTitle>
<DialogDescription>
<strong>{invoice.number}</strong> ve tüm kalemleri silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(false)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,303 +0,0 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Loader2, Plus, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
addInvoiceItemAction,
deleteInvoiceItemAction,
updateInvoiceItemAction,
} from "@/lib/appwrite/invoice-actions";
import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
import { formatTRY } from "@/lib/format";
export type InvoiceItemRow = {
id: string;
description: string;
quantity: number;
unitPrice: number;
vatRate: number;
lineTotal: number;
};
type Props = { invoiceId: string; items: InvoiceItemRow[] };
export function InvoiceItemsEditor({ invoiceId, items }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<InvoiceItemRow | null>(null);
const [deleting, setDeleting] = useState<InvoiceItemRow | null>(null);
const [busy, startTransition] = useTransition();
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteInvoiceItemAction(fd);
if (result.ok) {
toast.success("Kalem silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<>
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-sm font-semibold">Kalemler ({items.length})</h2>
<Button
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
Kalem ekle
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Açıklama</TableHead>
<TableHead className="w-[100px] text-right">Miktar</TableHead>
<TableHead className="w-[140px] text-right">Birim fiyat</TableHead>
<TableHead className="w-[80px] text-right">KDV %</TableHead>
<TableHead className="w-[140px] text-right">Toplam</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((it) => (
<TableRow
key={it.id}
className="cursor-pointer"
onClick={() => {
setEditing(it);
setFormOpen(true);
}}
>
<TableCell className="font-medium">{it.description}</TableCell>
<TableCell className="text-right tabular-nums">{it.quantity}</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.unitPrice)}
</TableCell>
<TableCell className="text-right tabular-nums">{it.vatRate}%</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.lineTotal)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={(e) => {
e.stopPropagation();
setDeleting(it);
}}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-20 text-center">
<p className="text-muted-foreground text-sm">
Henüz kalem eklenmemiş. Yukarıdan ekleyin.
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<ItemFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
invoiceId={invoiceId}
item={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kalemi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.description}</strong> kalemini silmek istediğinize emin misiniz?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function ItemFormSheet({
open,
onOpenChange,
invoiceId,
item,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
invoiceId: string;
item?: InvoiceItemRow | null;
}) {
const isEdit = Boolean(item);
const action = isEdit ? updateInvoiceItemAction : addInvoiceItemAction;
const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kalem güncellendi." : "Kalem eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kalemi düzenle" : "Yeni kalem"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && item && <input type="hidden" name="id" value={item.id} />}
{!isEdit && <input type="hidden" name="invoiceId" value={invoiceId} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="description">Açıklama *</Label>
<Input
id="description"
name="description"
defaultValue={item?.description ?? ""}
placeholder="Hizmet / ürün açıklaması"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="quantity">Miktar *</Label>
<Input
id="quantity"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue={item?.quantity ?? "1"}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="unitPrice">Birim () *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={item?.unitPrice ?? ""}
placeholder="0.00"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="vatRate">KDV %</Label>
<Input
id="vatRate"
name="vatRate"
type="number"
step="0.1"
min="0"
max="100"
defaultValue={item?.vatRate ?? "20"}
/>
</div>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Ekle"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
-134
View File
@@ -1,134 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { STATUS_COLOR, STATUS_LABEL } from "../components/types";
import { InvoiceItemsEditor } from "./components/items-editor";
import { InvoiceHeaderActions } from "./components/header-actions";
export const metadata: Metadata = {
title: "İşletmem — Fatura",
};
export default async function InvoiceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const invoice = await getInvoice(ctx.tenantId, id);
if (!invoice) notFound();
const [items, customers] = await Promise.all([
listInvoiceItems(ctx.tenantId, id),
listCustomers(ctx.tenantId),
]);
const customerName = customers.find((c) => c.$id === invoice.customerId)?.name ?? "—";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/invoices">
<ArrowLeft className="size-3.5" />
Faturalar
</Link>
</Button>
</div>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<p className="text-muted-foreground text-sm">{customerName}</p>
<h1 className="font-mono text-2xl font-bold tracking-tight">{invoice.number}</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs",
STATUS_COLOR[invoice.status ?? "draft"],
)}
>
{STATUS_LABEL[invoice.status ?? "draft"]}
</span>
<span className="text-muted-foreground">
Düzenleme: {formatDate(invoice.issueDate)}
</span>
<span className="text-muted-foreground">Vade: {formatDate(invoice.dueDate)}</span>
</div>
</div>
<InvoiceHeaderActions
invoice={{
id: invoice.$id,
number: invoice.number,
customerId: invoice.customerId,
customerName,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status ?? "draft",
subtotal: invoice.subtotal ?? 0,
vatTotal: invoice.vatTotal ?? 0,
total: invoice.total ?? 0,
notes: invoice.notes ?? "",
}}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
<InvoiceItemsEditor
invoiceId={id}
items={items.map((it) => ({
id: it.$id,
description: it.description,
quantity: it.quantity,
unitPrice: it.unitPrice,
vatRate: it.vatRate ?? 0,
lineTotal: it.lineTotal,
}))}
/>
<Card>
<CardContent className="space-y-2 p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Ara toplam</span>
<span className="tabular-nums">{formatTRY(invoice.subtotal ?? 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">KDV</span>
<span className="tabular-nums">{formatTRY(invoice.vatTotal ?? 0)}</span>
</div>
<div className="border-t pt-2"></div>
<div className="flex justify-between text-base font-semibold">
<span>Genel toplam</span>
<span className="tabular-nums">{formatTRY(invoice.total ?? 0)}</span>
</div>
</CardContent>
</Card>
{invoice.notes && (
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase">Notlar</p>
<p className="mt-1 whitespace-pre-line text-sm">{invoice.notes}</p>
</CardContent>
</Card>
)}
</div>
);
}
@@ -1,194 +0,0 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createInvoiceAction,
updateInvoiceAction,
} from "@/lib/appwrite/invoice-actions";
import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
import type { Customer, InvoiceRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
invoice?: InvoiceRow | null;
customers: Customer[];
};
function isoToDate(iso: string): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Props) {
const isEdit = Boolean(invoice);
const action = isEdit ? updateInvoiceAction : createInvoiceAction;
const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
const router = useRouter();
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Fatura güncellendi." : "Fatura oluşturuldu.");
onOpenChange(false);
if (!isEdit && state.invoiceId) {
router.push(`/invoices/${state.invoiceId}`);
}
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const today = new Date().toISOString().slice(0, 10);
const inThirty = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Faturayı düzenle" : "Yeni fatura"}</SheetTitle>
<SheetDescription>
{isEdit
? "Fatura bilgilerini güncelleyin. Kalem eklemek için fatura detayına gidin."
: "Faturayı oluşturun, ardından detay sayfasında kalemleri ekleyin. Numara otomatik üretilir."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && invoice && <input type="hidden" name="id" value={invoice.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri *</Label>
<Select
name="customerId"
defaultValue={invoice?.customerId ?? ""}
disabled={customers.length === 0}
>
<SelectTrigger id="customerId">
<SelectValue placeholder="Müşteri seçin" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.customerId && (
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="issueDate">Düzenleme tarihi *</Label>
<Input
id="issueDate"
name="issueDate"
type="date"
defaultValue={isoToDate(invoice?.issueDate ?? today)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Vade tarihi *</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={isoToDate(invoice?.dueDate ?? inThirty)}
required
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={invoice?.status ?? "draft"}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Taslak</SelectItem>
<SelectItem value="sent">Gönderildi</SelectItem>
<SelectItem value="paid">Ödendi</SelectItem>
<SelectItem value="overdue">Gecikmiş</SelectItem>
<SelectItem value="cancelled">İptal</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
&ldquo;Ödendi&rdquo; seçildiğinde finans modülüne otomatik gelir kaydı düşer.
Durum geri alınırsa kayıt silinir.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={4}
defaultValue={invoice?.notes ?? ""}
placeholder="Faturada görünecek not, ödeme talimatları, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending || customers.length === 0}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Oluştur"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,372 +0,0 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpRight,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
MoreHorizontal,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { InvoiceFormSheet } from "./invoice-form-sheet";
import { type Customer, type InvoiceRow, STATUS_COLOR, STATUS_LABEL } from "./types";
type Props = { invoices: InvoiceRow[]; customers: Customer[] };
export function InvoicesClient({ invoices, customers }: Props) {
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [deleting, setDeleting] = useState<InvoiceRow | null>(null);
const [busy, startTransition] = useTransition();
const stats = useMemo(() => {
let total = 0;
let outstanding = 0;
let paid = 0;
let overdue = 0;
for (const i of invoices) {
total += i.total;
if (i.status === "paid") paid += i.total;
else if (i.status === "overdue") {
outstanding += i.total;
overdue += i.total;
} else if (i.status === "sent" || i.status === "draft") outstanding += i.total;
}
return { total, outstanding, paid, overdue };
}, [invoices]);
const columns = useMemo<ColumnDef<InvoiceRow>[]>(
() => [
{
accessorKey: "number",
header: "Numara",
cell: ({ row }) => (
<Link
href={`/invoices/${row.original.id}`}
className="hover:text-primary inline-flex items-center gap-1 font-mono text-sm font-medium"
>
{row.original.number}
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) => row.original.customerName,
},
{
accessorKey: "issueDate",
header: "Tarih",
cell: ({ row }) => (
<span className="text-muted-foreground">{formatDate(row.original.issueDate)}</span>
),
},
{
accessorKey: "dueDate",
header: "Vade",
cell: ({ row }) => {
const overdue =
row.original.status !== "paid" &&
row.original.status !== "cancelled" &&
new Date(row.original.dueDate) < new Date();
return (
<span
className={cn(
"text-muted-foreground",
overdue && "text-destructive font-medium",
)}
>
{formatDate(row.original.dueDate)}
</span>
);
},
},
{
accessorKey: "status",
header: "Durum",
cell: ({ row }) => (
<Badge variant="outline" className={cn("border-0", STATUS_COLOR[row.original.status])}>
{STATUS_LABEL[row.original.status]}
</Badge>
),
},
{
accessorKey: "total",
header: "Toplam",
cell: ({ row }) => (
<span className="font-medium tabular-nums">{formatTRY(row.original.total)}</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/invoices/${row.original.id}`}>
<ArrowUpRight className="size-3.5" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: invoices,
columns,
state: { globalFilter: search, sorting },
onGlobalFilterChange: setSearch,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.number, row.original.customerName, row.original.notes]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteInvoiceAction(fd);
if (result.ok) {
toast.success("Fatura silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam</p>
<p className="mt-1 text-xl font-semibold">{formatTRY(stats.total)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Tahsil edildi</p>
<p className="mt-1 text-xl font-semibold text-emerald-600 dark:text-emerald-400">
{formatTRY(stats.paid)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Bekleyen</p>
<p className="mt-1 text-xl font-semibold text-blue-600 dark:text-blue-400">
{formatTRY(stats.outstanding)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Gecikmiş</p>
<p className="mt-1 text-xl font-semibold text-red-600 dark:text-red-400">
{formatTRY(stats.overdue)}
</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Numara, müşteri, not..."
className="pl-9"
/>
</div>
<Button onClick={() => setFormOpen(true)} disabled={customers.length === 0}>
<Plus className="size-4" />
Yeni fatura
</Button>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="group">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Receipt className="size-6" />
<p className="text-sm">
{customers.length === 0
? "Önce müşteri ekleyin, sonra fatura kesebilirsiniz."
: "Henüz fatura yok."}
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t px-4 py-3">
<p className="text-muted-foreground text-sm">
Toplam {table.getFilteredRowModel().rows.length} fatura
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm">
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<InvoiceFormSheet
open={formOpen}
onOpenChange={setFormOpen}
customers={customers}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Faturayı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.number}</strong> ve tüm kalemleri silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,33 +0,0 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
export type InvoiceRow = {
id: string;
number: string;
customerId: string;
customerName: string;
issueDate: string;
dueDate: string;
status: InvoiceStatus;
subtotal: number;
vatTotal: number;
total: number;
notes: string;
};
export type Customer = { id: string; name: string };
export const STATUS_LABEL: Record<InvoiceStatus, string> = {
draft: "Taslak",
sent: "Gönderildi",
paid: "Ödendi",
overdue: "Gecikmiş",
cancelled: "İptal",
};
export const STATUS_COLOR: Record<InvoiceStatus, string> = {
draft: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
sent: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
cancelled: "bg-muted text-muted-foreground border-muted-foreground/30",
};
-56
View File
@@ -1,56 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listInvoices } from "@/lib/appwrite/invoice-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { InvoicesClient } from "./components/invoices-client";
export const metadata: Metadata = {
title: "İşletmem — Faturalar",
};
export default async function InvoicesPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [invoices, customers] = await Promise.all([
listInvoices(ctx.tenantId),
listCustomers(ctx.tenantId),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
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">Faturalar</h1>
<p className="text-muted-foreground text-sm">
Müşterilerinize fatura kesin, kalemleri yönetin, durumu takip edin.
</p>
</div>
<InvoicesClient
invoices={invoices.map((i) => ({
id: i.$id,
number: i.number,
customerId: i.customerId,
customerName: customerMap.get(i.customerId) ?? "—",
issueDate: i.issueDate,
dueDate: i.dueDate,
status: i.status ?? "draft",
subtotal: i.subtotal ?? 0,
vatTotal: i.vatTotal ?? 0,
total: i.total ?? 0,
notes: i.notes ?? "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function DashboardLayout({
const company = { const company = {
id: ctx.tenantId, id: ctx.tenantId,
name: ctx.settings?.companyName ?? "Çalışma alanı", name: ctx.settings?.officeName ?? "Çalışma alanı",
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null, logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
}; };
const user = { const user = {
@@ -1,24 +0,0 @@
"use server";
import { listLeadActivities } from "@/lib/appwrite/lead-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import type { ActivityRow } from "./types";
export async function listLeadActivitiesForClient(leadId: string): Promise<ActivityRow[]> {
try {
const ctx = await requireTenant();
const rows = await listLeadActivities(ctx.tenantId, leadId);
return rows.map((a) => ({
id: a.$id,
leadId: a.leadId,
type: a.type,
content: a.content,
calendarEventId: a.calendarEventId ?? null,
occurredAt: a.occurredAt ?? null,
createdAt: a.$createdAt,
createdByName: "",
}));
} catch {
return [];
}
}
@@ -1,139 +0,0 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, GripVertical, MoreHorizontal, Pencil, Phone, Trash2, UserCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { formatCurrency, formatDate } from "@/lib/format";
import { LEAD_SOURCE_LABEL, LEAD_STATUS_CONFIG, type LeadRow } from "./types";
type Props = {
lead: LeadRow;
onEdit: (lead: LeadRow) => void;
onDetail: (lead: LeadRow) => void;
onDelete: (lead: LeadRow) => void;
isOverlay?: boolean;
};
export function LeadCard({ lead, onEdit, onDetail, onDelete, isOverlay }: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: lead.id,
data: { type: "lead", status: lead.status },
});
const style = { transform: CSS.Transform.toString(transform), transition };
const cfg = LEAD_STATUS_CONFIG[lead.status];
const isOverdue = lead.nextFollowUpAt && new Date(lead.nextFollowUpAt) < new Date();
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card group cursor-pointer rounded-lg border p-3 shadow-sm transition-shadow hover:shadow-md",
isDragging && "opacity-30",
isOverlay && "rotate-2 shadow-xl",
)}
onClick={() => onDetail(lead)}
>
<div className="flex items-start gap-2">
<button
{...attributes}
{...listeners}
aria-label="Sürükle"
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground mt-0.5 cursor-grab touch-none active:cursor-grabbing"
>
<GripVertical className="size-4" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold leading-snug">{lead.name}</h3>
{lead.contactName && (
<p className="text-muted-foreground truncate text-xs">{lead.contactName}</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDetail(lead); }}>
<UserCircle className="size-3.5" />
Detay & Aktiviteler
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit(lead); }}>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={(e) => { e.stopPropagation(); onDelete(lead); }}>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className={cn("border text-xs", cfg.color, cfg.bg, cfg.border)}>
{LEAD_SOURCE_LABEL[lead.source]}
</Badge>
{lead.estimatedValue != null && lead.estimatedValue > 0 && (
<Badge variant="secondary" className="text-xs font-medium">
{formatCurrency(lead.estimatedValue, lead.currency)}
</Badge>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{lead.phone && (
<span className="text-muted-foreground flex items-center gap-1">
<Phone className="size-3" />
{lead.phone}
</span>
)}
{lead.nextFollowUpAt && (
<span className={cn(
"flex items-center gap-1",
isOverdue ? "text-destructive font-medium" : "text-muted-foreground",
)}>
<Calendar className="size-3" />
{formatDate(lead.nextFollowUpAt)}
{isOverdue && " — gecikti"}
</span>
)}
</div>
{lead.assigneeName && (
<div className="mt-1.5 flex items-center gap-1">
<UserCircle className="text-muted-foreground size-3" />
<span className="text-muted-foreground text-xs">{lead.assigneeName}</span>
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,269 +0,0 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import {
Calendar, CheckCircle2, ChevronDown, Loader2, Mail, MessageSquarePlus,
Phone, TrendingUp, UserCheck, X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { formatCurrency, formatDateTime } from "@/lib/format";
import {
addLeadActivityAction,
scheduleFollowUpAction,
} from "@/lib/appwrite/lead-activity-actions";
import { convertLeadToCustomerAction } from "@/lib/appwrite/lead-actions";
import {
ACTIVITY_TYPE_CONFIG,
LEAD_SOURCE_LABEL,
LEAD_STATUS_CONFIG,
type ActivityRow,
type LeadActivityType,
type LeadRow,
} from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
lead: LeadRow | null;
activities: ActivityRow[];
onEdit: (lead: LeadRow) => void;
};
const ACTIVITY_TYPES: LeadActivityType[] = ["note", "call", "meeting", "email"];
export function LeadDetailSheet({ open, onOpenChange, lead, activities, onEdit }: Props) {
const [activityState, activityAction, activityPending] = useActionState(
addLeadActivityAction, { ok: false },
);
const [followUpState, followUpAction, followUpPending] = useActionState(
scheduleFollowUpAction, { ok: false },
);
const [convertBusy, startConvert] = useTransition();
const [tab, setTab] = useState<"activities" | "followup">("activities");
const [activityType, setActivityType] = useState<LeadActivityType>("note");
useEffect(() => {
if (activityState.ok) toast.success("Aktivite kaydedildi.");
else if (activityState.error) toast.error(activityState.error);
}, [activityState]);
useEffect(() => {
if (followUpState.ok) { toast.success("Takip takvime eklendi."); setTab("activities"); }
else if (followUpState.error) toast.error(followUpState.error);
}, [followUpState]);
const handleConvert = () => {
if (!lead) return;
startConvert(async () => {
const fd = new FormData();
fd.set("leadId", lead.id);
const result = await convertLeadToCustomerAction(fd);
if (result.ok) {
toast.success("Müşteriye dönüştürüldü.");
onOpenChange(false);
} else {
toast.error(result.error ?? "Dönüştürme başarısız.");
}
});
};
if (!lead) return null;
const cfg = LEAD_STATUS_CONFIG[lead.status];
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg">
<SheetHeader className="border-b px-6 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SheetTitle className="truncate">{lead.name}</SheetTitle>
{lead.contactName && (
<p className="text-muted-foreground mt-0.5 text-sm">{lead.contactName}</p>
)}
</div>
<Badge variant="outline" className={cn("shrink-0 text-xs", cfg.color, cfg.bg, cfg.border)}>
{cfg.label}
</Badge>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Info strip */}
<div className="bg-muted/30 flex flex-wrap gap-3 border-b px-6 py-3 text-xs">
{lead.phone && (
<a href={`tel:${lead.phone}`} className="text-muted-foreground hover:text-foreground flex items-center gap-1.5">
<Phone className="size-3.5" /> {lead.phone}
</a>
)}
{lead.email && (
<a href={`mailto:${lead.email}`} className="text-muted-foreground hover:text-foreground flex items-center gap-1.5">
<Mail className="size-3.5" /> {lead.email}
</a>
)}
{lead.estimatedValue != null && lead.estimatedValue > 0 && (
<span className="flex items-center gap-1.5 font-medium">
<TrendingUp className="size-3.5" />
{formatCurrency(lead.estimatedValue, lead.currency)}
</span>
)}
<span className="text-muted-foreground">{LEAD_SOURCE_LABEL[lead.source]}</span>
</div>
{/* Tab bar */}
<div className="flex border-b">
{(["activities", "followup"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
"flex-1 px-4 py-2.5 text-sm font-medium transition-colors",
tab === t
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "activities" ? "Aktiviteler" : "Takip Planla"}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{tab === "activities" && (
<div className="space-y-4 px-6 py-4">
{/* Add activity form */}
<form action={activityAction} className="space-y-3 rounded-lg border bg-muted/20 p-3">
<input type="hidden" name="leadId" value={lead.id} />
<div className="flex items-center gap-2">
<Select
name="type"
value={activityType}
onValueChange={(v) => setActivityType(v as LeadActivityType)}
>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVITY_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{ACTIVITY_TYPE_CONFIG[t].icon} {ACTIVITY_TYPE_CONFIG[t].label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">ekle</span>
</div>
<Textarea name="content" rows={2} placeholder="Notunuzu yazın…" className="text-sm" required />
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={activityPending}>
{activityPending
? <Loader2 className="size-3.5 animate-spin" />
: <MessageSquarePlus className="size-3.5" />}
Kaydet
</Button>
</div>
</form>
{/* Timeline */}
<div className="space-y-3">
{activities.length === 0 && (
<p className="text-muted-foreground py-4 text-center text-sm">Henüz aktivite yok.</p>
)}
{activities.map((a) => {
const acfg = ACTIVITY_TYPE_CONFIG[a.type];
return (
<div key={a.id} className="flex gap-3">
<div className="bg-muted flex size-7 shrink-0 items-center justify-center rounded-full text-sm">
{acfg.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{acfg.label}</span>
<span className="text-muted-foreground text-xs">
{formatDateTime(a.occurredAt ?? a.createdAt)}
</span>
</div>
<p className="mt-0.5 whitespace-pre-wrap text-sm">{a.content}</p>
</div>
</div>
);
})}
</div>
</div>
)}
{tab === "followup" && (
<form action={followUpAction} className="space-y-4 px-6 py-4">
<input type="hidden" name="leadId" value={lead.id} />
<div className="grid gap-2">
<Label htmlFor="followUpAt">Takip tarihi & saati</Label>
<Input
id="followUpAt"
name="followUpAt"
type="datetime-local"
min={new Date().toISOString().slice(0, 16)}
defaultValue={
lead.nextFollowUpAt
? new Date(lead.nextFollowUpAt).toISOString().slice(0, 16)
: ""
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="note">Not (isteğe bağlı)</Label>
<Textarea id="note" name="note" rows={2} placeholder="Görüşme konusu, hatırlatmalar…" />
</div>
<Button type="submit" className="w-full" disabled={followUpPending}>
{followUpPending
? <><Loader2 className="size-4 animate-spin" />Planlanıyor</>
: <><Calendar className="size-4" />Takvime ekle</>}
</Button>
{lead.nextFollowUpAt && (
<p className="text-muted-foreground text-center text-xs">
Mevcut takip: {formatDateTime(lead.nextFollowUpAt)}
</p>
)}
</form>
)}
</div>
{/* Footer actions */}
<div className="flex gap-2 border-t px-6 py-4">
<Button variant="outline" size="sm" onClick={() => onEdit(lead)} className="flex-1">
<ChevronDown className="size-3.5 rotate-90" />
Düzenle
</Button>
{lead.status !== "converted" && lead.status !== "lost" && (
<Button size="sm" onClick={handleConvert} disabled={convertBusy} className="flex-1 bg-green-600 hover:bg-green-700 text-white">
{convertBusy
? <Loader2 className="size-3.5 animate-spin" />
: <UserCheck className="size-3.5" />}
Müşteriye dönüştür
</Button>
)}
{lead.status === "converted" && (
<div className="flex flex-1 items-center justify-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" /> Müşteriye dönüştürüldü
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,199 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { ChevronDown, Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createLeadAction,
updateLeadAction,
} from "@/lib/appwrite/lead-actions";
import { initialLeadState } from "@/lib/appwrite/lead-types";
import { LEAD_SOURCE_LABEL, LEAD_STATUS_CONFIG, type LeadRow, type MemberOption } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
lead?: LeadRow | null;
defaultStatus?: string;
members: MemberOption[];
onCreated?: (leadId: string) => void;
};
export function LeadFormSheet({ open, onOpenChange, lead, defaultStatus, members, onCreated }: Props) {
const isEdit = Boolean(lead);
const action = isEdit ? updateLeadAction : createLeadAction;
const [state, formAction, isPending] = useActionState(action, initialLeadState);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeId, setAssigneeId] = useState(lead?.assigneeId ?? "");
useEffect(() => {
if (open) setAssigneeId(lead?.assigneeId ?? "");
}, [open, lead]);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Aday güncellendi." : "Aday eklendi.");
if (!isEdit && state.leadId) onCreated?.(state.leadId);
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const selectedMember = members.find((m) => m.id === assigneeId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-lg">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Adayı düzenle" : "Yeni müşteri adayı"}</SheetTitle>
<SheetDescription>Müşteri adayının bilgilerini girin.</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && lead && <input type="hidden" name="id" value={lead.id} />}
<input type="hidden" name="assigneeId" value={assigneeId} />
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
{/* Ad / Şirket */}
<div className="grid gap-2">
<Label htmlFor="name">Şirket / Lead adı *</Label>
<Input id="name" name="name" defaultValue={lead?.name ?? ""} placeholder="Örn. ABC Yazılım A.Ş." required />
{state.fieldErrors?.name && <p className="text-destructive text-xs">{state.fieldErrors.name}</p>}
</div>
{/* İlgili kişi */}
<div className="grid gap-2">
<Label htmlFor="contactName">İlgili kişi</Label>
<Input id="contactName" name="contactName" defaultValue={lead?.contactName ?? ""} placeholder="Ad Soyad" />
</div>
{/* Tel + Email */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={lead?.phone ?? ""} placeholder="+90 5xx xxx xx xx" />
</div>
<div className="grid gap-2">
<Label htmlFor="email">E-posta</Label>
<Input id="email" name="email" type="email" defaultValue={lead?.email ?? ""} placeholder="ornek@sirket.com" />
{state.fieldErrors?.email && <p className="text-destructive text-xs">{state.fieldErrors.email}</p>}
</div>
</div>
{/* Kaynak + Durum */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="source">Kaynak</Label>
<Select name="source" defaultValue={lead?.source ?? "other"}>
<SelectTrigger id="source"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.entries(LEAD_SOURCE_LABEL) as [string, string][]).map(([v, l]) => (
<SelectItem key={v} value={v}>{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={lead?.status ?? defaultStatus ?? "cold"}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.entries(LEAD_STATUS_CONFIG) as [string, { label: string }][]).map(([v, c]) => (
<SelectItem key={v} value={v}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Tahmini değer + para birimi */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2 sm:col-span-2">
<Label htmlFor="estimatedValue">Tahmini değer</Label>
<Input id="estimatedValue" name="estimatedValue" type="number" step="0.01" min="0"
defaultValue={lead?.estimatedValue ?? ""} placeholder="0.00" />
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para birimi</Label>
<Select name="currency" defaultValue={lead?.currency ?? "TRY"}>
<SelectTrigger id="currency"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TRY"> TRY</SelectItem>
<SelectItem value="USD">$ USD</SelectItem>
<SelectItem value="EUR"> EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Sorumlu */}
{members.length > 0 && (
<div className="grid gap-2">
<Label>Sorumlu</Label>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
{selectedMember ? (
<Badge variant="secondary" className="font-normal">{selectedMember.name}</Badge>
) : (
<span className="text-muted-foreground text-sm font-normal">Personel seçin</span>
)}
<ChevronDown className="text-muted-foreground size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="start">
<label className="flex cursor-pointer items-center gap-3 rounded px-3 py-2 hover:bg-muted">
<Checkbox checked={!assigneeId} onCheckedChange={() => { setAssigneeId(""); setAssigneeOpen(false); }} />
<span className="text-muted-foreground text-sm">Atanmamış</span>
</label>
{members.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-3 rounded px-3 py-2 hover:bg-muted">
<Checkbox checked={assigneeId === m.id} onCheckedChange={() => { setAssigneeId(m.id); setAssigneeOpen(false); }} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{m.name}</div>
<div className="text-muted-foreground truncate text-xs">{m.email}</div>
</div>
</label>
))}
</PopoverContent>
</Popover>
</div>
)}
{/* Notlar */}
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} defaultValue={lead?.notes ?? ""} placeholder="İlk görüşme notları, ihtiyaçlar vb." />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>Vazgeç</Button>
<Button type="submit" disabled={isPending}>
{isPending ? <><Loader2 className="size-4 animate-spin" />Kaydediliyor...</> : <><Save className="size-4" />{isEdit ? "Güncelle" : "Kaydet"}</>}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,255 +0,0 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
closestCorners,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { deleteLeadAction, moveLeadAction } from "@/lib/appwrite/lead-actions";
import { LeadCard } from "./lead-card";
import { LeadFormSheet } from "./lead-form-sheet";
import { LeadDetailSheet } from "./lead-detail-sheet";
import {
COLUMNS,
LEAD_STATUS_CONFIG,
type ActivityRow,
type LeadRow,
type LeadStatus,
type MemberOption,
} from "./types";
import { listLeadActivitiesForClient } from "./lead-activities-fetcher";
type Props = {
leads: LeadRow[];
members: MemberOption[];
currentUserId: string;
};
function Column({
status,
leads,
onAdd,
onEdit,
onDetail,
onDelete,
}: {
status: LeadStatus;
leads: LeadRow[];
onAdd: (status: LeadStatus) => void;
onEdit: (lead: LeadRow) => void;
onDetail: (lead: LeadRow) => void;
onDelete: (lead: LeadRow) => void;
}) {
const { setNodeRef, isOver } = useDroppable({
id: `col-${status}`,
data: { type: "column", status },
});
const cfg = LEAD_STATUS_CONFIG[status];
const totalValue = leads.reduce((s, l) => s + (l.estimatedValue ?? 0), 0);
return (
<div className="bg-muted/40 flex min-w-[260px] flex-col rounded-lg border">
<div className={cn("flex items-center justify-between border-b px-3 py-2.5", cfg.bg)}>
<div className="flex items-center gap-2">
<h2 className={cn("text-sm font-semibold", cfg.color)}>{LEAD_STATUS_CONFIG[status].label}</h2>
<Badge variant="secondary" className="rounded-full px-1.5 py-0.5 text-xs">{leads.length}</Badge>
</div>
<Button variant="ghost" size="icon" className="size-7" onClick={() => onAdd(status)} aria-label="Yeni aday">
<Plus className="size-3.5" />
</Button>
</div>
{totalValue > 0 && (
<div className="border-b px-3 py-1.5">
<span className="text-muted-foreground text-xs">
{new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 0 }).format(totalValue)}
</span>
</div>
)}
<div
ref={setNodeRef}
className={cn("flex flex-1 flex-col gap-2 p-2 transition-colors", isOver && "bg-primary/5")}
>
<SortableContext items={leads.map((l) => l.id)} strategy={verticalListSortingStrategy}>
{leads.map((lead) => (
<LeadCard key={lead.id} lead={lead} onEdit={onEdit} onDetail={onDetail} onDelete={onDelete} />
))}
</SortableContext>
{leads.length === 0 && (
<p className="text-muted-foreground py-8 text-center text-xs">Boş</p>
)}
</div>
</div>
);
}
export function LeadsBoard({ leads: initialLeads, members, currentUserId: _uid }: Props) {
const [leads, setLeads] = useState<LeadRow[]>(initialLeads);
const [activeId, setActiveId] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [formStatus, setFormStatus] = useState<LeadStatus>("cold");
const [editing, setEditing] = useState<LeadRow | null>(null);
const [detailLead, setDetailLead] = useState<LeadRow | null>(null);
const [detailActivities, setDetailActivities] = useState<ActivityRow[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [deleting, setDeleting] = useState<LeadRow | null>(null);
const [busy, startTransition] = useTransition();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
const grouped = useMemo(() => {
const map = Object.fromEntries(COLUMNS.map((c) => [c.key, [] as LeadRow[]])) as Record<LeadStatus, LeadRow[]>;
for (const l of leads) map[l.status].push(l);
return map;
}, [leads]);
const activeLead = useMemo(() => leads.find((l) => l.id === activeId) ?? null, [leads, activeId]);
const onDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id));
const onDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
setActiveId(null);
if (!over) return;
const overData = over.data.current as { type?: string; status?: LeadStatus } | undefined;
let targetStatus: LeadStatus | undefined;
if (overData?.type === "column") targetStatus = overData.status;
else if (overData?.type === "lead") targetStatus = overData.status;
if (!targetStatus) return;
const src = leads.find((l) => l.id === active.id);
if (!src || src.status === targetStatus) return;
setLeads((prev) => prev.map((l) => l.id === src.id ? { ...l, status: targetStatus! } : l));
startTransition(async () => {
const result = await moveLeadAction(src.id, targetStatus!);
if (!result.ok) {
setLeads((prev) => prev.map((l) => l.id === src.id ? { ...l, status: src.status } : l));
toast.error(result.error ?? "Taşıma başarısız.");
}
});
};
const openDetail = async (lead: LeadRow) => {
setDetailLead(lead);
setDetailActivities([]);
setDetailOpen(true);
const acts = await listLeadActivitiesForClient(lead.id);
setDetailActivities(acts);
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteLeadAction(fd);
if (result.ok) {
toast.success("Aday silindi.");
setLeads((prev) => prev.filter((l) => l.id !== deleting.id));
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
useMemo(() => setLeads(initialLeads), [initialLeads]);
return (
<>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Toplam {leads.length} aday
{leads.filter((l) => l.status === "converted").length > 0 && (
<span className="ml-2 text-green-600">
· {leads.filter((l) => l.status === "converted").length} kazanıldı
</span>
)}
</div>
<Button onClick={() => { setEditing(null); setFormStatus("cold"); setFormOpen(true); }}>
<Plus className="size-4" />
Yeni aday
</Button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={onDragStart} onDragEnd={onDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((col) => (
<Column
key={col.key}
status={col.key}
leads={grouped[col.key]}
onAdd={(s) => { setEditing(null); setFormStatus(s); setFormOpen(true); }}
onEdit={(l) => { setEditing(l); setFormOpen(true); }}
onDetail={openDetail}
onDelete={(l) => setDeleting(l)}
/>
))}
</div>
<DragOverlay>
{activeLead && (
<LeadCard lead={activeLead} onEdit={() => {}} onDetail={() => {}} onDelete={() => {}} isOverlay />
)}
</DragOverlay>
</DndContext>
<LeadFormSheet
open={formOpen}
onOpenChange={(v) => { setFormOpen(v); if (!v) setEditing(null); }}
lead={editing}
defaultStatus={formStatus}
members={members}
onCreated={(id) => {
// Detail sheet will reload on next open; no-op here
}}
/>
<LeadDetailSheet
open={detailOpen}
onOpenChange={setDetailOpen}
lead={detailLead}
activities={detailActivities}
onEdit={(l) => { setDetailOpen(false); setEditing(l); setFormOpen(true); }}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Adayı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.name}</strong> adlı aday ve tüm aktiviteleri kalıcı olarak silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>Vazgeç</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,72 +0,0 @@
import type { LeadActivityType, LeadSource, LeadStatus } from "@/lib/appwrite/schema";
export type { LeadStatus, LeadSource, LeadActivityType };
export type LeadRow = {
id: string;
name: string;
contactName: string;
email: string;
phone: string;
source: LeadSource;
status: LeadStatus;
estimatedValue: number | null;
currency: string;
notes: string;
assigneeId: string;
assigneeName: string;
lastContactAt: string | null;
nextFollowUpAt: string | null;
calendarEventId: string | null;
customerId: string | null;
createdAt: string;
};
export type ActivityRow = {
id: string;
leadId: string;
type: LeadActivityType;
content: string;
calendarEventId: string | null;
occurredAt: string | null;
createdAt: string;
createdByName: string;
};
export type MemberOption = { id: string; name: string; email: string };
export const LEAD_STATUS_CONFIG: Record<
LeadStatus,
{ label: string; color: string; bg: string; border: string }
> = {
cold: { label: "Soğuk", color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
warm: { label: "Ilık", color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/30" },
hot: { label: "Sıcak", color: "text-red-600 dark:text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
converted: { label: "Kazanıldı", color: "text-green-600 dark:text-green-400", bg: "bg-green-500/10", border: "border-green-500/30" },
lost: { label: "Kaybedildi",color: "text-muted-foreground", bg: "bg-muted/40", border: "border-border" },
};
export const LEAD_SOURCE_LABEL: Record<LeadSource, string> = {
website: "Website",
social: "Sosyal medya",
referral: "Referans",
cold_call: "Soğuk arama",
event: "Fuar / Etkinlik",
other: "Diğer",
};
export const ACTIVITY_TYPE_CONFIG: Record<LeadActivityType, { label: string; icon: string }> = {
note: { label: "Not", icon: "📝" },
call: { label: "Arama", icon: "📞" },
meeting: { label: "Toplantı", icon: "🤝" },
email: { label: "E-posta", icon: "✉️" },
status_change: { label: "Durum değişti", icon: "🔄" },
};
export const COLUMNS: { key: LeadStatus; title: string }[] = [
{ key: "cold", title: "Soğuk" },
{ key: "warm", title: "Ilık" },
{ key: "hot", title: "Sıcak" },
{ key: "converted", title: "Kazanıldı" },
{ key: "lost", title: "Kaybedildi" },
];
-76
View File
@@ -1,76 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listLeads } from "@/lib/appwrite/lead-queries";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LeadsBoard } from "./components/leads-board";
import type { LeadRow } from "./components/types";
export const metadata: Metadata = {
title: "İşletmem — Müşteri Adayları",
};
export default async function LeadsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const { teams } = createAdminClient();
const [leads, membershipsResult] = await Promise.all([
listLeads(ctx.tenantId),
teams.listMemberships(ctx.tenantId).catch(() => ({ memberships: [] })),
]);
const memberMap = new Map(
membershipsResult.memberships
.filter((m) => m.confirm)
.map((m) => [m.userId, m.userName || m.userEmail]),
);
const members = membershipsResult.memberships
.filter((m) => m.confirm)
.map((m) => ({ id: m.userId, name: m.userName || m.userEmail, email: m.userEmail }));
const leadRows: LeadRow[] = leads.map((l) => ({
id: l.$id,
name: l.name,
contactName: l.contactName ?? "",
email: l.email ?? "",
phone: l.phone ?? "",
source: l.source ?? "other",
status: l.status ?? "cold",
estimatedValue: l.estimatedValue ?? null,
currency: l.currency ?? "TRY",
notes: l.notes ?? "",
assigneeId: l.assigneeId ?? "",
assigneeName: l.assigneeId ? (memberMap.get(l.assigneeId) ?? "") : "",
lastContactAt: l.lastContactAt ?? null,
nextFollowUpAt: l.nextFollowUpAt ?? null,
calendarEventId: l.calendarEventId ?? null,
customerId: l.customerId ?? null,
createdAt: l.$createdAt,
}));
return (
<div className="flex-1 space-y-4 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">Müşteri Adayları</h1>
<p className="text-muted-foreground text-sm">
Müşteri adaylarını takip edin, ısıtın ve müşteriye dönüştürün.
</p>
</div>
<LeadsBoard
leads={leadRows}
members={members}
currentUserId={ctx.user.id}
/>
</div>
);
}
@@ -1,57 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface AccountSwitcherProps {
isCollapsed: boolean;
accounts: {
label: string;
email: string;
icon: React.ReactNode;
}[];
}
export function AccountSwitcher({ isCollapsed, accounts }: AccountSwitcherProps) {
const [selectedAccount, setSelectedAccount] = React.useState<string>(
accounts[0].email
);
return (
<Select defaultValue={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger
className={cn(
"flex items-center gap-2 w-full",
isCollapsed &&
"flex size-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden"
)}
aria-label="Select account"
>
<SelectValue placeholder="Select an account">
{accounts.find((account) => account.email === selectedAccount)?.icon}
<span className={cn("ml-2", isCollapsed && "hidden")}>
{accounts.find((account) => account.email === selectedAccount)?.label}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent position="popper" className="w-full">
{accounts.map((account) => (
<SelectItem key={account.email} value={account.email}>
<div className="flex items-center gap-3 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
{account.icon}
{account.email}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -1,191 +0,0 @@
"use client"
import { addDays } from "date-fns";
import { addHours } from "date-fns";
import { format } from "date-fns";
import { nextSaturday } from "date-fns";
import {
Archive,
ArchiveX,
Clock,
Forward,
MoreVertical,
Reply,
ReplyAll,
Trash2,
} from "lucide-react";
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { type Mail } from "../data";
import { useState } from "react";
interface MailDisplayProps {
mail: Mail | null;
}
export function MailDisplay({ mail }: MailDisplayProps) {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
return (
<div className="flex h-full flex-col">
<div className="flex items-center p-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" disabled={!mail} title="Archive" className="cursor-pointer disabled:cursor-not-allowed">
<Archive className="size-4" />
<span className="sr-only">Archive</span>
</Button>
<Button variant="ghost" size="icon" disabled={!mail} title="Move to junk" className="cursor-pointer disabled:cursor-not-allowed">
<ArchiveX className="size-4" />
<span className="sr-only">Move to junk</span>
</Button>
<Button variant="ghost" size="icon" disabled={!mail} title="Move to trash" className="cursor-pointer disabled:cursor-not-allowed">
<Trash2 className="size-4" />
<span className="sr-only">Move to trash</span>
</Button>
<Separator orientation="vertical" className="mx-1 h-6" />
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" disabled={!mail} title="Snooze" className="cursor-pointer disabled:cursor-not-allowed">
<Clock className="size-4" />
<span className="sr-only">Snooze</span>
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-auto p-0">
<div className="flex flex-col gap-2 border-r px-2 py-4">
<div className="px-4 text-sm font-medium">Snooze until</div>
<div className="grid min-w-[250px] gap-1">
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
Later today{" "}
<span className="text-muted-foreground ml-auto">
{format(addHours(selectedDate, 4), "E, h:mm b")}
</span>
</Button>
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
Tomorrow
<span className="text-muted-foreground ml-auto">
{format(addDays(selectedDate, 1), "E, h:mm b")}
</span>
</Button>
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
This weekend
<span className="text-muted-foreground ml-auto">
{format(nextSaturday(selectedDate), "E, h:mm b")}
</span>
</Button>
<Button variant="ghost" className="justify-start font-normal cursor-pointer">
Next week
<span className="text-muted-foreground ml-auto">
{format(addDays(selectedDate, 7), "E, h:mm b")}
</span>
</Button>
</div>
</div>
<div className="p-2">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
classNames={{
today: "bg-none",
day: "cursor-pointer",
day_selected: "cursor-pointer",
day_today: "cursor-pointer"
}}
required
/>
</div>
</PopoverContent>
</Popover>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="icon" disabled={!mail} title="Reply" className="cursor-pointer disabled:cursor-not-allowed">
<Reply className="size-4" />
<span className="sr-only">Reply</span>
</Button>
<Button variant="ghost" size="icon" disabled={!mail} title="Reply all" className="cursor-pointer disabled:cursor-not-allowed">
<ReplyAll className="size-4" />
<span className="sr-only">Reply all</span>
</Button>
<Button variant="ghost" size="icon" disabled={!mail} title="Forward" className="cursor-pointer disabled:cursor-not-allowed">
<Forward className="size-4" />
<span className="sr-only">Forward</span>
</Button>
</div>
<Separator orientation="vertical" className="mx-2 h-6" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={!mail} className="cursor-pointer disabled:cursor-not-allowed">
<MoreVertical className="size-4" />
<span className="sr-only">More</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">Mark as unread</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Star thread</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Add label</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">Mute thread</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Separator />
{mail ? (
<div className="flex flex-1 flex-col">
<div className="flex items-start p-4">
<div className="flex items-start gap-4 text-sm">
<Avatar className="cursor-pointer">
<AvatarImage alt={mail.name} />
<AvatarFallback>
{mail.name
.split(" ")
.map((chunk) => chunk[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<div className="font-semibold">{mail.name}</div>
<div className="line-clamp-1 text-xs">{mail.subject}</div>
<div className="line-clamp-1 text-xs">
<span className="font-medium">Reply-To:</span> {mail.email}
</div>
</div>
</div>
{mail.date && (
<div className="text-muted-foreground ml-auto text-xs">
{format(new Date(mail.date), "PPpp")}
</div>
)}
</div>
<Separator />
<div className="flex-1 p-4 text-sm whitespace-pre-wrap">{mail.text}</div>
<Separator className="mt-auto" />
<div className="p-4">
<form>
<div className="grid gap-4">
<Textarea className="p-4 cursor-text" placeholder={`Reply ${mail.name}...`} />
<div className="flex items-center">
<Label htmlFor="mute" className="flex items-center gap-2 text-xs font-normal cursor-pointer">
<Switch id="mute" aria-label="Mute thread" /> Mute this thread
</Label>
<Button onClick={(e) => e.preventDefault()} size="sm" className="ml-auto cursor-pointer">
Send
</Button>
</div>
</div>
</form>
</div>
</div>
) : (
<div className="text-muted-foreground p-8 text-center">No message selected</div>
)}
</div>
);
}
@@ -1,83 +0,0 @@
"use client"
import type { ComponentProps } from "react"
import { formatDistanceToNow } from "date-fns"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import type { Mail } from "../data"
import { useMail } from "../use-mail"
interface MailListProps {
items: Mail[];
}
export function MailList({ items }: MailListProps) {
const [mail, setMail] = useMail();
return (
<ScrollArea className="h-[calc(100vh-12rem)]">
<div className="flex flex-col gap-2 p-4 pt-0">{items.map((item) => (
<button
key={item.id}
className={cn(
"hover:bg-accent hover:text-accent-foreground flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all cursor-pointer",
mail.selected === item.id && "bg-muted"
)}
onClick={() =>
setMail({
...mail,
selected: item.id,
})
}
>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center">
<div className="flex items-center gap-2">
<div className="font-semibold">{item.name}</div>
{!item.read && <span className="flex size-2 rounded-full bg-blue-600 cursor-pointer" />}
</div>
<div
className={cn(
"ml-auto text-xs",
mail.selected === item.id ? "text-foreground" : "text-muted-foreground"
)}
>
{formatDistanceToNow(new Date(item.date), {
addSuffix: true,
})}
</div>
</div>
<div className="text-xs font-medium">{item.subject}</div>
</div>
<div className="text-muted-foreground line-clamp-2 text-xs">
{item.text.substring(0, 300)}
</div>
{item.labels.length ? (
<div className="flex items-center gap-2">
{item.labels.map((label) => (
<Badge key={label} variant={getBadgeVariantFromLabel(label)} className="cursor-pointer">
{label}
</Badge>
))}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
);
}
function getBadgeVariantFromLabel(label: string): ComponentProps<typeof Badge>["variant"] {
if (["work"].includes(label.toLowerCase())) {
return "default";
}
if (["personal"].includes(label.toLowerCase())) {
return "outline";
}
return "secondary";
}
@@ -1,207 +0,0 @@
"use client"
import * as React from "react"
import {
AlertCircle,
Archive,
ArchiveX,
File,
Inbox,
MessagesSquare,
Search,
Send,
ShoppingCart,
Trash2,
Users2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip"
import { AccountSwitcher } from "./account-switcher"
import { MailDisplay } from "./mail-display"
import { MailList } from "./mail-list"
import { Nav } from "./nav"
import { type Mail } from "../data"
import { useMail } from "../use-mail"
import { Button } from "@/components/ui/button"
interface MailProps {
accounts: {
label: string;
email: string;
icon: React.ReactNode;
}[];
mails: Mail[];
defaultLayout?: number[];
defaultCollapsed?: boolean;
navCollapsedSize: number;
}
export function Mail({
accounts,
mails,
defaultLayout = [20, 32, 48],
defaultCollapsed = false,
navCollapsedSize,
}: MailProps) {
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed);
const [mail] = useMail();
return (
<TooltipProvider delayDuration={0}>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes: number[]) => {
document.cookie = `react-resizable-panels:layout:mail=${JSON.stringify(sizes)}`;
}}
className="h-full items-stretch rounded-lg border overflow-hidden"
>
<ResizablePanel
defaultSize={defaultLayout[0]}
collapsedSize={navCollapsedSize}
collapsible={true}
minSize={15}
maxSize={20}
onCollapse={() => {
setIsCollapsed(true);
document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(true)}`;
}}
onResize={() => {
setIsCollapsed(false);
document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(false)}`;
}}
className={cn(isCollapsed && "w-full transition-all duration-300 ease-in-out")}
>
<div
className={cn(
"flex h-[52px] items-center justify-center",
isCollapsed ? "h-[52px]" : "px-2"
)}
>
<AccountSwitcher isCollapsed={isCollapsed} accounts={accounts} />
</div>
<Separator className="mx-0" />
<div className="m-3">
<Button className="w-full cursor-pointer">
{isCollapsed ? "" : "Compose"}
<Send className="size-4" />
</Button>
</div>
<Separator className="mx-0" />
<Nav
isCollapsed={isCollapsed}
links={[
{
title: "Inbox",
label: "128",
icon: Inbox,
variant: "default",
},
{
title: "Drafts",
label: "9",
icon: File,
variant: "ghost",
},
{
title: "Sent",
label: "",
icon: Send,
variant: "ghost",
},
{
title: "Junk",
label: "23",
icon: ArchiveX,
variant: "ghost",
},
{
title: "Trash",
label: "",
icon: Trash2,
variant: "ghost",
},
{
title: "Archive",
label: "",
icon: Archive,
variant: "ghost",
},
]}
/>
<Separator className="mx-0" />
<Nav
isCollapsed={isCollapsed}
links={[
{
title: "Social",
label: "972",
icon: Users2,
variant: "ghost",
},
{
title: "Updates",
label: "342",
icon: AlertCircle,
variant: "ghost",
},
{
title: "Forums",
label: "128",
icon: MessagesSquare,
variant: "ghost",
},
{
title: "Shopping",
label: "8",
icon: ShoppingCart,
variant: "ghost",
},
{
title: "Promotions",
label: "21",
icon: Archive,
variant: "ghost",
},
]}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={defaultLayout[1]} minSize={30}>
<Tabs defaultValue="all" className="gap-1">
<div className="flex items-center px-4 py-1.5">
<h1 className="text-foreground text-xl font-bold">Inbox</h1>
<TabsList className="ml-auto">
<TabsTrigger value="all" className="cursor-pointer">All mail</TabsTrigger>
<TabsTrigger value="unread" className="cursor-pointer">Unread</TabsTrigger>
</TabsList>
</div>
<Separator />
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 p-4 backdrop-blur">
<form>
<div className="relative">
<Search className="text-muted-foreground absolute top-2.5 left-2 size-4 cursor-pointer" />
<Input placeholder="Search" className="pl-8 cursor-text" />
</div>
</form>
</div>
<TabsContent value="all" className="m-0">
<MailList items={mails} />
</TabsContent>
<TabsContent value="unread" className="m-0">
<MailList items={mails.filter((item) => !item.read)} />
</TabsContent>
</Tabs>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={defaultLayout[2]} minSize={30}>
<MailDisplay mail={mails.find((item) => item.id === mail.selected) || null} />
</ResizablePanel>
</ResizablePanelGroup>
</TooltipProvider>
);
}
@@ -1,81 +0,0 @@
"use client"
import { type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
interface NavProps {
isCollapsed: boolean;
links: {
title: string;
label?: string;
icon: LucideIcon;
variant: "default" | "ghost";
}[];
}
export function Nav({ links, isCollapsed }: NavProps) {
return (
<div
data-collapsed={isCollapsed}
className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
>
<nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{links.map((link, index) =>
isCollapsed ? (
<Tooltip key={index} delayDuration={0}>
<TooltipTrigger asChild>
<button
className={cn(
buttonVariants({ variant: link.variant, size: "icon" }),
"size-9 cursor-pointer",
link.variant === "default" &&
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white"
)}
>
<link.icon className="size-4" />
<span className="sr-only">{link.title}</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-4">
{link.title}
{link.label && (
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full cursor-pointer">
{link.label}
</Badge>
)}
</TooltipContent>
</Tooltip>
) : (
<button
key={index}
className={cn(
buttonVariants({ variant: link.variant, size: "sm" }),
link.variant === "default" &&
"group dark:bg-muted dark:text-foreground dark:hover:bg-muted dark:hover:text-foreground",
"justify-start cursor-pointer"
)}
>
<link.icon className="mr-2 size-4" />
{link.title}
{link.label && (
<span
className={cn(
"ml-auto",
link.variant === "default" &&
"text-background dark:text-muted-foreground"
)}
>
{link.label}
</span>
)}
</button>
)
)}
</nav>
</div>
);
}
-300
View File
@@ -1,300 +0,0 @@
export const mails = [
{
id: "6c84fb90-12c4-11e1-840d-7b25c5ee775a",
name: "William Smith",
email: "williamsmith@example.com",
subject: "Meeting Tomorrow",
text: "Hi, let's have a meeting tomorrow to discuss the project. I've been reviewing the project details and have some ideas I'd like to share. It's crucial that we align on our next steps to ensure the project's success.\n\nPlease come prepared with any questions or insights you may have. Looking forward to our meeting!\n\nBest regards, William",
date: "2023-10-22T09:00:00",
read: true,
labels: ["meeting", "work", "important"],
},
{
id: "110e8400-e29b-11d4-a716-446655440000",
name: "Alice Smith",
email: "alicesmith@example.com",
subject: "Re: Project Update",
text: "Thank you for the project update. It looks great! I've gone through the report, and the progress is impressive. The team has done a fantastic job, and I appreciate the hard work everyone has put in.\n\nI have a few minor suggestions that I'll include in the attached document.\n\nLet's discuss these during our next meeting. Keep up the excellent work!\n\nBest regards, Alice",
date: "2023-10-22T10:30:00",
read: true,
labels: ["work", "important"],
},
{
id: "3e7c3f6d-bdf5-46ae-8d90-171300f27ae2",
name: "Bob Johnson",
email: "bobjohnson@example.com",
subject: "Weekend Plans",
text: "Any plans for the weekend? I was thinking of going hiking in the nearby mountains. It's been a while since we had some outdoor fun.\n\nIf you're interested, let me know, and we can plan the details. It'll be a great way to unwind and enjoy nature.\n\nLooking forward to your response!\n\nBest, Bob",
date: "2023-04-10T11:45:00",
read: true,
labels: ["personal"],
},
{
id: "61c35085-72d7-42b4-8d62-738f700d4b92",
name: "Emily Davis",
email: "emilydavis@example.com",
subject: "Re: Question about Budget",
text: "I have a question about the budget for the upcoming project. It seems like there's a discrepancy in the allocation of resources.\n\nI've reviewed the budget report and identified a few areas where we might be able to optimize our spending without compromising the project's quality.\n\nI've attached a detailed analysis for your reference. Let's discuss this further in our next meeting.\n\nThanks, Emily",
date: "2023-03-25T13:15:00",
read: false,
labels: ["work", "budget"],
},
{
id: "8f7b5db9-d935-4e42-8e05-1f1d0a3dfb97",
name: "Michael Wilson",
email: "michaelwilson@example.com",
subject: "Important Announcement",
text: "I have an important announcement to make during our team meeting. It pertains to a strategic shift in our approach to the upcoming product launch. We've received valuable feedback from our beta testers, and I believe it's time to make some adjustments to better meet our customers' needs.\n\nThis change is crucial to our success, and I look forward to discussing it with the team. Please be prepared to share your insights during the meeting.\n\nRegards, Michael",
date: "2023-03-10T15:00:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "1f0f2c02-e299-40de-9b1d-86ef9e42126b",
name: "Sarah Brown",
email: "sarahbrown@example.com",
subject: "Re: Feedback on Proposal",
text: "Thank you for your feedback on the proposal. It looks great! I'm pleased to hear that you found it promising. The team worked diligently to address all the key points you raised, and I believe we now have a strong foundation for the project.\n\nI've attached the revised proposal for your review.\n\nPlease let me know if you have any further comments or suggestions. Looking forward to your response.\n\nBest regards, Sarah",
date: "2023-02-15T16:30:00",
read: true,
labels: ["work"],
},
{
id: "17c0a96d-4415-42b1-8b4f-764efab57f66",
name: "David Lee",
email: "davidlee@example.com",
subject: "New Project Idea",
text: "I have an exciting new project idea to discuss with you. It involves expanding our services to target a niche market that has shown considerable growth in recent months.\n\nI've prepared a detailed proposal outlining the potential benefits and the strategy for execution.\n\nThis project has the potential to significantly impact our business positively. Let's set up a meeting to dive into the details and determine if it aligns with our current goals.\n\nBest regards, David",
date: "2023-01-28T17:45:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "2f0130cb-39fc-44c4-bb3c-0a4337edaaab",
name: "Olivia Wilson",
email: "oliviawilson@example.com",
subject: "Vacation Plans",
text: "Let's plan our vacation for next month. What do you think? I've been thinking of visiting a tropical paradise, and I've put together some destination options.\n\nI believe it's time for us to unwind and recharge. Please take a look at the options and let me know your preferences.\n\nWe can start making arrangements to ensure a smooth and enjoyable trip.\n\nExcited to hear your thoughts! Olivia",
date: "2022-12-20T18:30:00",
read: true,
labels: ["personal"],
},
{
id: "de305d54-75b4-431b-adb2-eb6b9e546014",
name: "James Martin",
email: "jamesmartin@example.com",
subject: "Re: Conference Registration",
text: "I've completed the registration for the conference next month. The event promises to be a great networking opportunity, and I'm looking forward to attending the various sessions and connecting with industry experts.\n\nI've also attached the conference schedule for your reference.\n\nIf there are any specific topics or sessions you'd like me to explore, please let me know. It's an exciting event, and I'll make the most of it.\n\nBest regards, James",
date: "2022-11-30T19:15:00",
read: true,
labels: ["work", "conference"],
},
{
id: "7dd90c63-00f6-40f3-bd87-5060a24e8ee7",
name: "Sophia White",
email: "sophiawhite@example.com",
subject: "Team Dinner",
text: "Let's have a team dinner next week to celebrate our success. We've achieved some significant milestones, and it's time to acknowledge our hard work and dedication.\n\nI've made reservations at a lovely restaurant, and I'm sure it'll be an enjoyable evening.\n\nPlease confirm your availability and any dietary preferences. Looking forward to a fun and memorable dinner with the team!\n\nBest, Sophia",
date: "2022-11-05T20:30:00",
read: false,
labels: ["meeting", "work"],
},
{
id: "99a88f78-3eb4-4d87-87b7-7b15a49a0a05",
name: "Daniel Johnson",
email: "danieljohnson@example.com",
subject: "Feedback Request",
text: "I'd like your feedback on the latest project deliverables. We've made significant progress, and I value your input to ensure we're on the right track.\n\nI've attached the deliverables for your review, and I'm particularly interested in any areas where you think we can further enhance the quality or efficiency.\n\nYour feedback is invaluable, and I appreciate your time and expertise. Let's work together to make this project a success.\n\nRegards, Daniel",
date: "2022-10-22T09:30:00",
read: false,
labels: ["work"],
},
{
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
name: "Ava Taylor",
email: "avataylor@example.com",
subject: "Re: Meeting Agenda",
text: "Here's the agenda for our meeting next week. I've included all the topics we need to cover, as well as time allocations for each.\n\nIf you have any additional items to discuss or any specific points to address, please let me know, and we can integrate them into the agenda.\n\nIt's essential that our meeting is productive and addresses all relevant matters.\n\nLooking forward to our meeting! Ava",
date: "2022-10-10T10:45:00",
read: true,
labels: ["meeting", "work"],
},
{
id: "c1a0ecb4-2540-49c5-86f8-21e5ce79e4e6",
name: "William Anderson",
email: "williamanderson@example.com",
subject: "Product Launch Update",
text: "The product launch is on track. I'll provide an update during our call. We've made substantial progress in the development and marketing of our new product.\n\nI'm excited to share the latest updates with you during our upcoming call. It's crucial that we coordinate our efforts to ensure a successful launch. Please come prepared with any questions or insights you may have.\n\nLet's make this product launch a resounding success!\n\nBest regards, William",
date: "2022-09-20T12:00:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "ba54eefd-4097-4949-99f2-2a9ae4d1a836",
name: "Mia Harris",
email: "miaharris@example.com",
subject: "Re: Travel Itinerary",
text: "I've received the travel itinerary. It looks great! Thank you for your prompt assistance in arranging the details. I've reviewed the schedule and the accommodations, and everything seems to be in order. I'm looking forward to the trip, and I'm confident it'll be a smooth and enjoyable experience.\n\nIf there are any specific activities or attractions you recommend at our destination, please feel free to share your suggestions.\n\nExcited for the trip! Mia",
date: "2022-09-10T13:15:00",
read: true,
labels: ["personal", "travel"],
},
{
id: "df09b6ed-28bd-4e0c-85a9-9320ec5179aa",
name: "Ethan Clark",
email: "ethanclark@example.com",
subject: "Team Building Event",
text: "Let's plan a team-building event for our department. Team cohesion and morale are vital to our success, and I believe a well-organized team-building event can be incredibly beneficial. I've done some research and have a few ideas for fun and engaging activities.\n\nPlease let me know your thoughts and availability. We want this event to be both enjoyable and productive.\n\nTogether, we'll strengthen our team and boost our performance.\n\nRegards, Ethan",
date: "2022-08-25T15:30:00",
read: false,
labels: ["meeting", "work"],
},
{
id: "d67c1842-7f8b-4b4b-9be1-1b3b1ab4611d",
name: "Chloe Hall",
email: "chloehall@example.com",
subject: "Re: Budget Approval",
text: "The budget has been approved. We can proceed with the project. I'm delighted to inform you that our budget proposal has received the green light from the finance department. This is a significant milestone, and it means we can move forward with the project as planned.\n\nI've attached the finalized budget for your reference. Let's ensure that we stay on track and deliver the project on time and within budget.\n\nIt's an exciting time for us! Chloe",
date: "2022-08-10T16:45:00",
read: true,
labels: ["work", "budget"],
},
{
id: "6c9a7f94-8329-4d70-95d3-51f68c186ae1",
name: "Samuel Turner",
email: "samuelturner@example.com",
subject: "Weekend Hike",
text: "Who's up for a weekend hike in the mountains? I've been craving some outdoor adventure, and a hike in the mountains sounds like the perfect escape. If you're up for the challenge, we can explore some scenic trails and enjoy the beauty of nature.\n\nI've done some research and have a few routes in mind.\n\nLet me know if you're interested, and we can plan the details.\n\nIt's sure to be a memorable experience! Samuel",
date: "2022-07-28T17:30:00",
read: false,
labels: ["personal"],
},
]
export type Mail = (typeof mails)[number]
export const accounts = [
{
label: "Alicia Koch",
email: "alicia@example.com",
icon: (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Gmail</title>
<path
d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"
fill="currentColor"
/>
</svg>
),
},
{
label: "Alicia Koch",
email: "alicia2@example.com",
icon: (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Vercel</title>
<path d="M24 22.525H0l12-21.05 12 21.05z" fill="currentColor" />
</svg>
),
},
{
label: "Alicia Koch",
email: "alicia3@example.com",
icon: (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>iCloud</title>
<path
d="M13.762 4.29a6.51 6.51 0 0 0-5.669 3.332 3.571 3.571 0 0 0-1.558-.36 3.571 3.571 0 0 0-3.516 3A4.918 4.918 0 0 0 0 14.796a4.918 4.918 0 0 0 4.92 4.914 4.93 4.93 0 0 0 .617-.045h14.42c2.305-.272 4.041-2.258 4.043-4.589v-.009a4.594 4.594 0 0 0-3.727-4.508 6.51 6.51 0 0 0-6.511-6.27z"
fill="currentColor"
/>
</svg>
),
},
]
export type Account = (typeof accounts)[number]
export const contacts = [
{
name: "Emma Johnson",
email: "emma.johnson@example.com",
},
{
name: "Liam Wilson",
email: "liam.wilson@example.com",
},
{
name: "Olivia Davis",
email: "olivia.davis@example.com",
},
{
name: "Noah Martinez",
email: "noah.martinez@example.com",
},
{
name: "Ava Taylor",
email: "ava.taylor@example.com",
},
{
name: "Lucas Brown",
email: "lucas.brown@example.com",
},
{
name: "Sophia Smith",
email: "sophia.smith@example.com",
},
{
name: "Ethan Wilson",
email: "ethan.wilson@example.com",
},
{
name: "Isabella Jackson",
email: "isabella.jackson@example.com",
},
{
name: "Mia Clark",
email: "mia.clark@example.com",
},
{
name: "Mason Lee",
email: "mason.lee@example.com",
},
{
name: "Layla Harris",
email: "layla.harris@example.com",
},
{
name: "William Anderson",
email: "william.anderson@example.com",
},
{
name: "Ella White",
email: "ella.white@example.com",
},
{
name: "James Thomas",
email: "james.thomas@example.com",
},
{
name: "Harper Lewis",
email: "harper.lewis@example.com",
},
{
name: "Benjamin Moore",
email: "benjamin.moore@example.com",
},
{
name: "Aria Hall",
email: "aria.hall@example.com",
},
{
name: "Henry Turner",
email: "henry.turner@example.com",
},
{
name: "Scarlett Adams",
email: "scarlett.adams@example.com",
},
]
export type Contact = (typeof contacts)[number]
-6
View File
@@ -1,6 +0,0 @@
import { Mail } from "./components/mail";
import { accounts, mails } from "./data";
export default function MailPage() {
return <Mail accounts={accounts} mails={mails} navCollapsedSize={4} />;
}
-18
View File
@@ -1,18 +0,0 @@
import { Mail } from "./components/mail"
import { accounts, mails } from "./data"
export default function MailPage() {
return (
<div className="@container/main flex flex-1 flex-col">
<div className="h-[calc(100vh-4rem)] px-4 md:px-6">
<Mail
accounts={accounts}
mails={mails}
defaultLayout={[20, 32, 48]}
defaultCollapsed={false}
navCollapsedSize={4}
/>
</div>
</div>
)
}
-20
View File
@@ -1,20 +0,0 @@
import { create } from "zustand";
import type { Mail } from "./data";
import { mails } from "./data";
interface Config {
selected: Mail["id"] | null;
}
const useMailStore = create<
Config & { setState: (newState: Partial<Config>) => void }
>((set) => ({
selected: mails[0].id,
setState: (newState) => set((state) => ({ ...state, ...newState })),
}));
export function useMail(): [Config, (newState: Partial<Config>) => void] {
const selected = useMailStore((state) => state.selected);
const setState = useMailStore((state) => state.setState);
return [{ selected }, setState];
}
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold capitalize">presentations</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
@@ -1,52 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
interface FAQ {
id: number
question: string
answer: string
}
interface FAQSectionProps {
faqs: FAQ[]
}
export function FAQSection({ faqs }: FAQSectionProps) {
return (
<Card className="mt-6 sm:mt-8 lg:mt-12">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Frequently Asked Questions</CardTitle>
<CardDescription>
Get answers to the most common questions about our pricing and plans
</CardDescription>
</CardHeader>
<CardContent className="mt-6 sm:mt-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6">
{/* Left Column */}
<div className="space-y-4">
<Accordion type='multiple'>
{faqs.slice(0, 3).map(item => (
<AccordionItem key={item.id} value={`item-${item.id}`} className='rounded-md !border my-3'>
<AccordionTrigger className='cursor-pointer px-4'>{item.question}</AccordionTrigger>
<AccordionContent className='text-muted-foreground px-4'>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
{/* Right Column */}
<div className="space-y-4">
<Accordion type='multiple'>
{faqs.slice(3, 6).map(item => (
<AccordionItem key={item.id} value={`item-${item.id}`} className='rounded-md !border my-3'>
<AccordionTrigger className='cursor-pointer px-4'>{item.question}</AccordionTrigger>
<AccordionContent className='text-muted-foreground px-4'>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -1,56 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Rocket, Shield, Zap, Users, Headphones, Clock } from "lucide-react"
// Icon mapping
const iconMap = {
Rocket,
Shield,
Zap,
Users,
Headphones,
Clock,
}
interface Feature {
id: number
name: string
description: string
icon: string
}
interface FeaturesGridProps {
features: Feature[]
}
export function FeaturesGrid({ features }: FeaturesGridProps) {
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">All Plans Include</CardTitle>
<CardDescription>
Every plan comes with these essential features to help your team succeed
</CardDescription>
</CardHeader>
<CardContent>
<div className='mx-auto mt-6 sm:mt-8 lg:mt-12'>
<dl className='grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-y-16'>
{features.map(feature => {
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
return (
<div key={feature.name} className='relative pl-16'>
<div className='text-base leading-7 font-semibold'>
<div className='bg-accent absolute start-0 top-0 flex h-10 w-10 items-center justify-center rounded-lg'>
<IconComponent className='text-foreground size-6' aria-hidden='true' />
</div>
<span className='text-lg'>{feature.name}</span>
</div>
<p className='text-muted-foreground mt-2 text-base leading-relaxed'>{feature.description}</p>
</div>
)
})}
</dl>
</div>
</CardContent>
</Card>
)
}
@@ -1,32 +0,0 @@
[
{
"id": 1,
"question": "Can I change my plan anytime?",
"answer": "Yes, you can upgrade or downgrade your plan at any time. Changes will be reflected in your next billing cycle, and you'll be charged or credited accordingly."
},
{
"id": 2,
"question": "Is there a free trial available?",
"answer": "Yes, all plans come with a 14-day free trial. No credit card is required to start your trial, and you can explore all features during this period."
},
{
"id": 3,
"question": "What payment methods do you accept?",
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely."
},
{
"id": 4,
"question": "Do you offer discounts for annual plans?",
"answer": "Yes, save 20% when you choose annual billing on any plan. You can switch to annual billing from your account settings at any time."
},
{
"id": 5,
"question": "What happens if I exceed my plan limits?",
"answer": "If you exceed your plan limits, we'll notify you in advance. You can either upgrade your plan or purchase additional resources as needed."
},
{
"id": 6,
"question": "Can I cancel my subscription anytime?",
"answer": "Yes, you can cancel your subscription at any time from your account settings. You'll continue to have access to all features until the end of your current billing period."
}
]
@@ -1,38 +0,0 @@
[
{
"id": 1,
"name": "Fast Performance",
"description": "Lightning-fast response times and optimized performance for all your business needs.",
"icon": "Rocket"
},
{
"id": 2,
"name": "Enterprise Security",
"description": "Bank-level security with end-to-end encryption and advanced threat protection.",
"icon": "Shield"
},
{
"id": 3,
"name": "Instant Setup",
"description": "Get up and running in minutes with our streamlined onboarding process.",
"icon": "Zap"
},
{
"id": 4,
"name": "Team Collaboration",
"description": "Seamless collaboration tools to keep your team connected and productive.",
"icon": "Users"
},
{
"id": 5,
"name": "24/7 Support",
"description": "Round-the-clock expert support whenever you need help or have questions.",
"icon": "Headphones"
},
{
"id": 6,
"name": "Real-time Analytics",
"description": "Monitor your business performance with real-time insights and detailed analytics.",
"icon": "Clock"
}
]
-295
View File
@@ -1,295 +0,0 @@
import { redirect } from "next/navigation";
import { Building2, Check, Clock, Crown, Sparkles, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import {
RESOURCE_LABELS,
getEffectivePlan,
getPlanUsage,
type PlanResource,
} from "@/lib/appwrite/plan-limits";
import {
downgradeToFreeAction,
startCheckoutAction,
} from "@/lib/appwrite/subscription-actions";
import { isShopierEnabled } from "@/lib/payments/shopier";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
const trFmt = new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
maximumFractionDigits: 0,
});
type EcosystemTier = {
id: "klinik" | "ajans";
name: string;
description: string;
Icon: typeof Stethoscope;
features: string[];
};
const ECOSYSTEM_TIERS: EcosystemTier[] = [
{
id: "klinik",
name: "Kliniğim",
description: "Hekim, klinik ve sağlık merkezleri için.",
Icon: Stethoscope,
features: [
"Hasta kaydı + KVKK uyumlu dosyalama",
"Randevu + hatırlatma",
"Reçete ve tetkik takibi",
"Klinik finans paneli",
],
},
{
id: "ajans",
name: "Ajansım",
description: "Yaratıcı ajanslar ve danışmanlıklar için.",
Icon: Building2,
features: [
"Proje + saat takibi",
"Müşteri portalı",
"Brief + onay akışı",
"Ajans bazlı raporlama",
],
},
];
export default async function PricingPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const currentPlan = getEffectivePlan(ctx);
const isPro = currentPlan === "pro";
const canManage = ctx.role === "owner";
const usage = await getPlanUsage(ctx);
const shopierActive = isShopierEnabled();
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
const tiers = [
{
...PLAN_CATALOG.free,
isCurrent: !isPro,
isPopular: false,
},
{
...PLAN_CATALOG.pro,
isCurrent: isPro,
isPopular: true,
},
];
return (
<div className="flex-1 space-y-8 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">Plan</h1>
<p className="text-muted-foreground text-sm">
İşletmem'i ölçeğine göre kullan. Sektörel paketler (Kliniğim, Ajansım) yakında.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Bu ayki kullanımın</CardTitle>
<CardDescription>
Mevcut planın sınırlarına ne kadar yaklaştığını gör.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{resources.map((r) => {
const u = usage.usage[r];
const pct =
u.limit === Number.POSITIVE_INFINITY
? 0
: Math.min(100, Math.round((u.used / Math.max(1, u.limit)) * 100));
const limitLabel =
u.limit === Number.POSITIVE_INFINITY ? "∞" : String(u.limit);
return (
<div key={r} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="capitalize">{RESOURCE_LABELS[r]}</span>
<span
className={cn(
"font-mono text-xs",
u.reached
? "text-destructive font-semibold"
: "text-muted-foreground",
)}
>
{u.used} / {limitLabel}
</span>
</div>
{u.limit !== Number.POSITIVE_INFINITY && (
<Progress value={pct} className="h-1.5" />
)}
</div>
);
})}
</CardContent>
</Card>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">İşletmem planları</h2>
<Badge variant="outline" className="text-xs">
Tek para birimi: ₺ (TRY)
</Badge>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{tiers.map((tier) => (
<Card
key={tier.id}
className={cn("flex flex-col pt-0", {
"border-primary relative shadow-lg": tier.isPopular,
"border-primary": tier.isCurrent,
})}
>
{tier.isCurrent && (
<div className="absolute start-0 -top-3 w-full">
<Badge className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
<Sparkles className="!size-4" />
Mevcut plan
</Badge>
</div>
)}
{tier.isPopular && !tier.isCurrent && (
<div className="absolute start-0 -top-3 w-full">
<Badge variant="secondary" className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
<Crown className="!size-4" />
Önerilen
</Badge>
</div>
)}
<CardHeader className="space-y-2 pt-8 text-center">
<CardTitle className="text-2xl">{tier.name}</CardTitle>
<p className="text-muted-foreground text-sm text-balance">{tier.description}</p>
</CardHeader>
<CardContent className="flex flex-1 flex-col space-y-6">
<div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">{trFmt.format(tier.price)}</span>
<span className="text-muted-foreground text-sm">/ay</span>
</div>
<div className="space-y-2">
{tier.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="bg-muted rounded-full p-1">
<Check className="size-3.5" />
</div>
<span className="text-sm">{feature}</span>
</div>
))}
</div>
</CardContent>
<CardFooter>
{tier.isCurrent ? (
<Button className="w-full" size="lg" variant="outline" disabled>
Mevcut plan
</Button>
) : !canManage ? (
<Button className="w-full" size="lg" variant="outline" disabled>
Sahip yetkisi gerekli
</Button>
) : tier.id === "pro" ? (
<form action={startCheckoutAction} className="w-full">
<input type="hidden" name="plan" value="pro" />
<Button type="submit" className="w-full" size="lg">
<Crown className="size-4" />
{shopierActive ? "Pro'ya geç" : "Pro'ya geç (Test)"}
</Button>
</form>
) : (
<form action={downgradeToFreeAction} className="w-full">
<Button type="submit" className="w-full" size="lg" variant="outline">
Ücretsiz'e dön
</Button>
</form>
)}
</CardFooter>
</Card>
))}
</div>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Ekosistem paketleri</h2>
<Badge variant="outline" className="text-xs">
<Clock className="size-3" />
Yakında
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Sektörel modüller İşletmem'in üzerine eklenecek. Aynı hesabınla farklı şirketleri tek
panelden yöneteceksin.
</p>
<div className="grid gap-6 lg:grid-cols-2">
{ECOSYSTEM_TIERS.map((t) => (
<Card key={t.id} className="flex flex-col bg-muted/30">
<CardHeader>
<div className="mb-2 flex items-center justify-between">
<div className="bg-background flex size-10 items-center justify-center rounded-md border">
<t.Icon className="size-5" />
</div>
<Badge variant="outline" className="text-xs">
Yakında
</Badge>
</div>
<CardTitle className="text-xl">{t.name}</CardTitle>
<p className="text-muted-foreground text-sm">{t.description}</p>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="space-y-2">
{t.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="bg-background rounded-full p-1">
<Check className="size-3.5 text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm">{feature}</span>
</div>
))}
</div>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" variant="outline" disabled>
Geliştirme aşamasında
</Button>
</CardFooter>
</Card>
))}
</div>
</section>
{!shopierActive && (
<Card className="bg-muted/20">
<CardContent className="text-muted-foreground py-4 text-xs">
<p>
<span className="text-foreground font-medium">Test modu:</span> Pro plan şu anda mock
ödeme akışıyla çalışır. Shopier entegrasyonu aktif edilince gerçek tahsilat başlayacak.
</p>
</CardContent>
</Card>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<h1 className="text-2xl font-bold capitalize">properties</h1>
<p className="text-muted-foreground">Yakında...</p>
</div>
);
}
@@ -1,76 +0,0 @@
"use client";
import { useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteServiceAction } from "@/lib/appwrite/service-actions";
export function DeleteServiceDialog({
open,
onOpenChange,
id,
name,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
id: string | null;
name: string;
}) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!id) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
const result = await deleteServiceAction(fd);
if (result.ok) {
toast.success("Hizmet silindi.");
onOpenChange(false);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hizmeti sil</DialogTitle>
<DialogDescription>
<strong>{name}</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Siliniyor...
</>
) : (
<>
<Trash2 className="size-4" />
Sil
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,350 +0,0 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Check, ChevronDown, Loader2, Save, Users } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
createServiceAction,
updateServiceAction,
} from "@/lib/appwrite/service-actions";
import { initialServiceState } from "@/lib/appwrite/service-types";
import type { CustomerOption, MemberOption, ServiceRow } from "./types";
const PRESET_SERVICES = [
"Web sitesi tasarımı",
"Web sitesi bakımı",
"SEO optimizasyonu",
"Sosyal medya yönetimi",
"Domain kayıt / yenileme",
"Hosting hizmeti",
"Kurumsal e-posta",
"Grafik tasarım",
"Logo tasarımı",
"Google Ads yönetimi",
"Meta Ads yönetimi",
"Yazılım geliştirme",
"Mobil uygulama",
"Teknik destek",
"Muhasebe danışmanlığı",
"Eğitim / danışmanlık",
];
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
service?: ServiceRow | null;
customers: CustomerOption[];
defaultCustomerId?: string;
members: MemberOption[];
};
export function ServiceFormSheet({
open,
onOpenChange,
service,
customers,
defaultCustomerId,
members,
}: Props) {
const isEdit = Boolean(service);
const action = isEdit ? updateServiceAction : createServiceAction;
const [state, formAction, isPending] = useActionState(action, initialServiceState);
const [name, setName] = useState(service?.name ?? "");
const [assigneeIds, setAssigneeIds] = useState<string[]>(service?.assigneeIds ?? []);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// Reset local state when sheet opens with a different service
useEffect(() => {
if (open) {
setName(service?.name ?? "");
setAssigneeIds(service?.assigneeIds ?? []);
}
}, [open, service]);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Hizmet güncellendi." : "Hizmet eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const toggleAssignee = (id: string) => {
setAssigneeIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Hizmeti düzenle" : "Yeni hizmet"}</SheetTitle>
<SheetDescription>
{customers.length === 0
? "Hizmet eklemek için önce en az bir müşteri tanımlamalısınız."
: "Müşteriye sunduğunuz hizmeti tanımlayın."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && service && <input type="hidden" name="id" value={service.id} />}
{/* Assignee hidden inputs — one per selected member */}
{assigneeIds.map((id) => (
<input key={id} type="hidden" name="assigneeIds" value={id} />
))}
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
{/* Müşteri */}
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri *</Label>
<Select
name="customerId"
defaultValue={service?.customerId ?? defaultCustomerId ?? ""}
disabled={customers.length === 0}
>
<SelectTrigger id="customerId">
<SelectValue placeholder="Müşteri seçin" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.customerId && (
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
)}
</div>
{/* Hizmet adı + hazır şablonlar */}
<div className="grid gap-2">
<Label htmlFor="name">Hizmet adı *</Label>
{/* Preset chips */}
<div className="flex flex-wrap gap-1.5">
{PRESET_SERVICES.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setName(preset)}
className={cn(
"rounded-full border px-2.5 py-0.5 text-xs transition-colors",
name === preset
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/40 text-muted-foreground hover:border-primary/50 hover:text-foreground",
)}
>
{preset}
</button>
))}
</div>
<Input
id="name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hizmet adını yazın veya yukarıdan seçin"
required
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
{/* Açıklama */}
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={service?.description ?? ""}
placeholder="Hizmetin kapsamı, sınırları, vb."
/>
</div>
{/* Fiyat + Para birimi */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2 sm:col-span-2">
<Label htmlFor="unitPrice">Birim fiyat *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={service?.unitPrice ?? ""}
placeholder="0.00"
required
/>
{state.fieldErrors?.unitPrice && (
<p className="text-destructive text-xs">{state.fieldErrors.unitPrice}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para birimi</Label>
<Select name="currency" defaultValue={service?.currency ?? "TRY"}>
<SelectTrigger id="currency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TRY"> TRY</SelectItem>
<SelectItem value="USD">$ USD</SelectItem>
<SelectItem value="EUR"> EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Faturalama dönemi + Tekrarlayan */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="billingPeriod">Faturalama dönemi</Label>
<Select
name="billingPeriod"
defaultValue={service?.billingPeriod ?? "onetime"}
>
<SelectTrigger id="billingPeriod">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="onetime">Tek seferlik</SelectItem>
<SelectItem value="monthly">Aylık</SelectItem>
<SelectItem value="yearly">Yıllık</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-0.5">
<div className="flex w-full items-center justify-between rounded-md border p-3">
<Label htmlFor="recurring" className="cursor-pointer text-sm">
Tekrarlayan
</Label>
<Switch
id="recurring"
name="recurring"
defaultChecked={service?.recurring}
/>
</div>
</div>
</div>
{/* Sorumlu personel */}
{members.length > 0 && (
<div className="grid gap-2">
<Label>Sorumlu personel</Label>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="h-auto min-h-10 w-full justify-between px-3 py-2"
>
<div className="flex flex-wrap gap-1">
{assigneeIds.length === 0 ? (
<span className="text-muted-foreground text-sm font-normal">
Personel seçin (isteğe bağlı)
</span>
) : (
assigneeIds.map((id) => {
const m = members.find((x) => x.id === id);
return (
<Badge key={id} variant="secondary" className="font-normal">
{m?.name ?? id}
</Badge>
);
})
)}
</div>
<ChevronDown className="text-muted-foreground ml-2 size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="start">
{members.map((m) => {
const checked = assigneeIds.includes(m.id);
return (
<label
key={m.id}
className="flex cursor-pointer items-center gap-3 rounded px-3 py-2 hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleAssignee(m.id)}
/>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{m.name}</div>
<div className="text-muted-foreground truncate text-xs">{m.email}</div>
</div>
{checked && <Check className="text-primary size-3.5 shrink-0" />}
</label>
);
})}
</PopoverContent>
</Popover>
</div>
)}
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending || customers.length === 0}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -1,333 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpDown,
Briefcase,
ChevronLeft,
ChevronRight,
MoreHorizontal,
Pencil,
Plus,
Repeat,
Search,
Trash2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { BILLING_PERIOD_LABEL, formatCurrency } from "@/lib/format";
import { ServiceFormSheet } from "./service-form-sheet";
import { DeleteServiceDialog } from "./delete-service-dialog";
import type { CustomerOption, MemberOption, ServiceRow } from "./types";
type Props = {
services: ServiceRow[];
customers: CustomerOption[];
members: MemberOption[];
};
export function ServicesClient({ services, customers, members }: Props) {
const memberMap = useMemo(() => new Map(members.map((m) => [m.id, m.name])), [members]);
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<ServiceRow | null>(null);
const [deleting, setDeleting] = useState<ServiceRow | null>(null);
const columns = useMemo<ColumnDef<ServiceRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Hizmet
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.name}</span>
{row.original.description && (
<span className="text-muted-foreground line-clamp-1 text-xs">
{row.original.description}
</span>
)}
</div>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) => <span className="text-muted-foreground">{row.original.customerName}</span>,
},
{
accessorKey: "unitPrice",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Fiyat
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{formatCurrency(row.original.unitPrice, row.original.currency)}
</span>
),
},
{
accessorKey: "billingPeriod",
header: "Dönem",
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<Badge variant="outline">{BILLING_PERIOD_LABEL[row.original.billingPeriod]}</Badge>
{row.original.recurring && (
<Repeat className="text-muted-foreground size-3.5" aria-label="Tekrarlayan" />
)}
</div>
),
},
{
id: "assignees",
header: "Personel",
cell: ({ row }) => {
const names = row.original.assigneeIds
.map((id) => memberMap.get(id))
.filter(Boolean) as string[];
if (names.length === 0) return <span className="text-muted-foreground text-xs"></span>;
return (
<div className="flex flex-wrap gap-1">
{names.map((n) => (
<Badge key={n} variant="secondary" className="text-xs font-normal">
{n}
</Badge>
))}
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditing(row.original);
setFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: services,
columns,
state: { globalFilter, sorting },
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
globalFilterFn: (row, _id, filterValue) => {
const v = String(filterValue).toLowerCase();
const assigneeNames = row.original.assigneeIds.map((id) => memberMap.get(id) ?? "").join(" ");
return [row.original.name, row.original.customerName, row.original.description, assigneeNames]
.join(" ")
.toLowerCase()
.includes(v);
},
});
return (
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Hizmet adı, müşteri..."
className="pl-9"
/>
</div>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
disabled={customers.length === 0}
>
<Plus className="size-4" />
Yeni hizmet
</Button>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Briefcase className="size-6" />
<p className="text-sm">
{customers.length === 0
? "Önce bir müşteri ekleyin, sonra hizmet tanımlayabilirsiniz."
: "Henüz hizmet eklenmemiş."}
</p>
{customers.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk hizmeti ekle
</Button>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t px-4 py-3">
<p className="text-muted-foreground text-sm">
Toplam {table.getFilteredRowModel().rows.length} hizmet
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm">
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</CardContent>
<ServiceFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
service={editing}
customers={customers}
members={members}
/>
<DeleteServiceDialog
open={Boolean(deleting)}
onOpenChange={(v) => !v && setDeleting(null)}
id={deleting?.id ?? null}
name={deleting?.name ?? ""}
/>
</Card>
);
}
@@ -1,17 +0,0 @@
export type ServiceRow = {
id: string;
customerId: string;
customerName: string;
name: string;
description: string;
unitPrice: number;
currency: string;
recurring: boolean;
billingPeriod: "monthly" | "yearly" | "onetime";
assigneeIds: string[];
createdAt: string;
};
export type CustomerOption = { id: string; name: string };
export type MemberOption = { id: string; name: string; email: string };
-63
View File
@@ -1,63 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listServices } from "@/lib/appwrite/service-queries";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { ServicesClient } from "./components/services-client";
export const metadata: Metadata = {
title: "İşletmem — Hizmetler",
};
export default async function ServicesPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const { teams } = createAdminClient();
const [services, customers, membershipsResult] = await Promise.all([
listServices(ctx.tenantId),
listCustomers(ctx.tenantId),
teams.listMemberships(ctx.tenantId).catch(() => ({ memberships: [] })),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
const members = membershipsResult.memberships
.filter((m) => m.confirm)
.map((m) => ({ id: m.userId, name: m.userName || m.userEmail, email: m.userEmail }));
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">Hizmetler</h1>
<p className="text-muted-foreground text-sm">
Müşterilere sunduğunuz hizmetleri ve fiyatlarını yönetin.
</p>
</div>
<ServicesClient
services={services.map((s) => ({
id: s.$id,
customerId: s.customerId,
customerName: customerMap.get(s.customerId) ?? "—",
name: s.name,
description: s.description ?? "",
unitPrice: s.unitPrice,
currency: s.currency ?? "TRY",
recurring: Boolean(s.recurring),
billingPeriod: s.billingPeriod ?? "onetime",
assigneeIds: s.assigneeIds ?? [],
createdAt: s.$createdAt,
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
members={members}
/>
</div>
);
}
@@ -1,151 +0,0 @@
"use client";
import { Cpu, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";
type Brand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
function detectBrand(num: string): Brand {
const n = num.replace(/\s/g, "");
if (/^4/.test(n)) return "visa";
if (/^(5[1-5]|2[2-7])/.test(n)) return "mastercard";
if (/^3[47]/.test(n)) return "amex";
if (/^(9792|65)/.test(n)) return "troy";
return "unknown";
}
function BrandLogo({ brand }: { brand: Brand }) {
const base = "text-white/95 font-black tracking-tight";
if (brand === "visa") {
return <span className={cn(base, "text-2xl italic font-serif")}>VISA</span>;
}
if (brand === "mastercard") {
return (
<div className="flex items-center">
<div className="size-7 rounded-full bg-red-500" />
<div className="-ml-3 size-7 rounded-full bg-amber-400 mix-blend-screen" />
</div>
);
}
if (brand === "amex") {
return <span className={cn(base, "text-base")}>AMEX</span>;
}
if (brand === "troy") {
return <span className={cn(base, "text-xl tracking-widest")}>troy</span>;
}
return (
<div className="size-8 rounded-full border-2 border-white/40 border-dashed" />
);
}
function maskedNumber(num: string): string {
const digits = num.replace(/\D/g, "").slice(0, 16);
const padded = digits.padEnd(16, "•");
return padded.match(/.{1,4}/g)?.join(" ") ?? "";
}
type Props = {
number: string;
name: string;
expiry: string;
cvc: string;
flipped: boolean;
};
export function CreditCardVisual({ number, name, expiry, cvc, flipped }: Props) {
const brand = detectBrand(number);
const display = maskedNumber(number);
const cvcDisplay = cvc.padEnd(3, "•").slice(0, 4);
return (
<div className="w-full" style={{ perspective: "1200px" }}>
<div
className={cn(
"relative aspect-[1.586/1] w-full transition-transform duration-700 will-change-transform",
)}
style={{
transformStyle: "preserve-3d",
transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)",
}}
>
{/* FRONT */}
<div
className="absolute inset-0 rounded-2xl p-5 sm:p-6 shadow-xl overflow-hidden"
style={{
backfaceVisibility: "hidden",
background:
"linear-gradient(135deg, oklch(0.32 0.04 260) 0%, oklch(0.20 0.04 260) 60%, oklch(0.12 0.03 260) 100%)",
}}
>
<div
className="absolute inset-0 opacity-30 mix-blend-overlay"
style={{
background:
"radial-gradient(circle at 20% 0%, rgba(255,255,255,0.4), transparent 40%), radial-gradient(circle at 80% 100%, rgba(255,255,255,0.2), transparent 50%)",
}}
/>
<div className="relative flex h-full flex-col justify-between text-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Cpu className="size-9 text-amber-300/80" strokeWidth={1.25} />
<Wifi className="size-5 rotate-90 text-white/60" />
</div>
<BrandLogo brand={brand} />
</div>
<div className="space-y-3">
<div className="font-mono text-lg tracking-[0.2em] sm:text-xl">
{display}
</div>
<div className="grid grid-cols-[1fr_auto] gap-4 text-xs sm:text-sm">
<div>
<div className="text-white/50 text-[10px] uppercase tracking-wider">
Kart sahibi
</div>
<div className="font-medium uppercase truncate">
{name || "AD SOYAD"}
</div>
</div>
<div className="text-right">
<div className="text-white/50 text-[10px] uppercase tracking-wider">
Son kullanma
</div>
<div className="font-mono">{expiry || "AA/YY"}</div>
</div>
</div>
</div>
</div>
</div>
{/* BACK */}
<div
className="absolute inset-0 rounded-2xl shadow-xl overflow-hidden"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
background:
"linear-gradient(135deg, oklch(0.28 0.04 260) 0%, oklch(0.18 0.04 260) 60%, oklch(0.10 0.03 260) 100%)",
}}
>
<div className="mt-6 h-12 w-full bg-black/80" />
<div className="px-5 pt-5 sm:px-6">
<div className="flex items-center gap-3">
<div className="bg-white/90 flex h-9 flex-1 items-center justify-end rounded-sm px-3 font-mono text-sm text-slate-800">
{cvcDisplay}
</div>
<div className="text-white/70 text-[10px] uppercase tracking-wider">
CVC
</div>
</div>
<div className="text-white/40 mt-6 text-[10px] leading-relaxed">
Bu kart yalnızca İşletmem mock test akışı içindir. Gerçek bir banka kartı
değildir, hiçbir tahsilat yapılmaz.
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,379 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ArrowLeft, CreditCard, Loader2, Lock, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
cancelMockPaymentAction,
confirmMockPaymentAction,
} from "@/lib/appwrite/subscription-actions";
import type { CardBrand, SavedCard } from "@/lib/appwrite/schema";
import { CreditCardVisual } from "./credit-card-visual";
const trFmt = new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
maximumFractionDigits: 0,
});
const BRAND_LABEL: Record<CardBrand, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "Amex",
troy: "troy",
unknown: "Kart",
};
function formatNumber(v: string): string {
return v
.replace(/\D/g, "")
.slice(0, 16)
.replace(/(.{4})/g, "$1 ")
.trim();
}
function formatExpiry(v: string): string {
const digits = v.replace(/\D/g, "").slice(0, 4);
if (digits.length < 3) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
function formatCvc(v: string): string {
return v.replace(/\D/g, "").slice(0, 4);
}
function detectBrand(num: string): CardBrand {
const n = num.replace(/\s/g, "");
if (/^4/.test(n)) return "visa";
if (/^(5[1-5]|2[2-7])/.test(n)) return "mastercard";
if (/^3[47]/.test(n)) return "amex";
if (/^(9792|65)/.test(n)) return "troy";
return "unknown";
}
type Props = {
orderId: string;
amount: number;
planName: string;
planPeriod: string;
savedCards: SavedCard[];
};
export function MockPaymentForm({
orderId,
amount,
planName,
planPeriod,
savedCards,
}: Props) {
const defaultSaved = savedCards.find((c) => c.isDefault) ?? savedCards[0] ?? null;
const [mode, setMode] = useState<"saved" | "new">(defaultSaved ? "saved" : "new");
const [selectedCardId, setSelectedCardId] = useState<string>(defaultSaved?.$id ?? "");
const [number, setNumber] = useState("");
const [name, setName] = useState("");
const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState("");
const [flipped, setFlipped] = useState(false);
const [saveCard, setSaveCard] = useState(true);
const [confirming, startConfirm] = useTransition();
const [cancelling, startCancel] = useTransition();
const numberDigits = number.replace(/\s/g, "");
const expiryDigits = expiry.replace(/\D/g, "");
const newCardFilled =
numberDigits.length === 16 &&
name.trim().length >= 3 &&
expiryDigits.length === 4 &&
cvc.length >= 3;
const filled = mode === "saved" ? Boolean(selectedCardId) : newCardFilled;
const handleConfirm = () => {
const fd = new FormData();
fd.set("orderId", orderId);
if (mode === "saved" && selectedCardId) {
fd.set("savedCardId", selectedCardId);
} else {
const month = expiryDigits.slice(0, 2);
const year = expiryDigits.slice(2, 4);
fd.set("cardLast4", numberDigits.slice(-4));
fd.set("cardExpiryMonth", month);
fd.set("cardExpiryYear", `20${year}`);
fd.set("cardBrand", detectBrand(numberDigits));
fd.set("cardHolder", name.trim());
fd.set("saveCard", saveCard ? "true" : "false");
}
startConfirm(() => confirmMockPaymentAction(fd));
};
const handleCancel = () => {
const fd = new FormData();
fd.set("orderId", orderId);
startCancel(() => cancelMockPaymentAction(fd));
};
const busy = confirming || cancelling;
// Visual sync — saved card preview when in saved mode
const selected = savedCards.find((c) => c.$id === selectedCardId);
const visualNumber =
mode === "saved" && selected
? `${"•".repeat(12)} ${selected.last4}`
: number;
const visualName =
mode === "saved" && selected ? selected.holderName ?? "" : name;
const visualExpiry =
mode === "saved" && selected
? `${String(selected.expiryMonth).padStart(2, "0")}/${String(selected.expiryYear).slice(2)}`
: expiry;
const visualCvc = mode === "saved" ? "" : cvc;
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
<div className="space-y-4">
<CreditCardVisual
number={visualNumber}
name={visualName}
expiry={visualExpiry}
cvc={visualCvc}
flipped={flipped}
/>
<div className="bg-emerald-500/5 flex items-start gap-2 rounded-md border p-3 text-xs">
<ShieldCheck className="size-4 text-emerald-600 shrink-0" />
<span className="text-muted-foreground">
Test modu gerçek kart bilgisi gerekmez. Onayladığında plan{" "}
<span className="text-foreground font-medium">{planPeriod}</span> boyunca aktif
olur, tahsilat yapılmaz.
</span>
</div>
</div>
<div className="space-y-5">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase tracking-wider">
Ödenecek tutar
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{trFmt.format(amount)}</span>
<span className="text-muted-foreground text-sm">{planName}</span>
</div>
</div>
{savedCards.length > 0 && (
<div className="grid grid-cols-2 gap-2 rounded-md border p-1">
<button
type="button"
className={cn(
"rounded px-3 py-2 text-sm font-medium transition-colors",
mode === "saved"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setMode("saved")}
disabled={busy}
>
Kayıtlı kart
</button>
<button
type="button"
className={cn(
"rounded px-3 py-2 text-sm font-medium transition-colors",
mode === "new"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setMode("new")}
disabled={busy}
>
Yeni kart
</button>
</div>
)}
{mode === "saved" && savedCards.length > 0 ? (
<div className="space-y-2">
{savedCards.map((c) => (
<label
key={c.$id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selectedCardId === c.$id
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
>
<input
type="radio"
name="savedCard"
className="size-4"
checked={selectedCardId === c.$id}
onChange={() => setSelectedCardId(c.$id)}
disabled={busy}
/>
<CreditCard className="text-muted-foreground size-5 shrink-0" />
<div className="flex-1 text-sm">
<div className="font-medium">
{BRAND_LABEL[c.brand ?? "unknown"]} {c.last4}
</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>
{c.isDefault && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
Varsayılan
</span>
)}
</label>
))}
</div>
) : (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cc-number">Kart numarası</Label>
<Input
id="cc-number"
inputMode="numeric"
autoComplete="cc-number"
placeholder="1234 5678 9012 3456"
value={number}
onFocus={() => setFlipped(false)}
onChange={(e) => setNumber(formatNumber(e.target.value))}
className="font-mono tracking-wider"
disabled={busy}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cc-name">Kart üzerindeki ad</Label>
<Input
id="cc-name"
autoComplete="cc-name"
placeholder="AD SOYAD"
value={name}
onFocus={() => setFlipped(false)}
onChange={(e) => setName(e.target.value.toUpperCase())}
className="uppercase"
disabled={busy}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cc-expiry">Son kullanma</Label>
<Input
id="cc-expiry"
inputMode="numeric"
autoComplete="cc-exp"
placeholder="AA/YY"
value={expiry}
onFocus={() => setFlipped(false)}
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
className="font-mono"
disabled={busy}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cc-cvc">CVC</Label>
<Input
id="cc-cvc"
inputMode="numeric"
autoComplete="cc-csc"
placeholder="123"
value={cvc}
onFocus={() => setFlipped(true)}
onBlur={() => setFlipped(false)}
onChange={(e) => setCvc(formatCvc(e.target.value))}
className="font-mono"
disabled={busy}
/>
</div>
</div>
<label className="flex items-start gap-3 rounded-md border bg-muted/30 p-3 text-sm">
<Checkbox
id="save-card"
checked={saveCard}
onCheckedChange={(v) => setSaveCard(Boolean(v))}
disabled={busy}
className="mt-0.5"
/>
<div className="flex-1">
<div className="font-medium">Bu kartı kaydet</div>
<div className="text-muted-foreground mt-0.5 text-xs">
Sonraki ödemelerde tek tıkla kullan. Kart numarasının yalnızca son 4 hanesi,
markası ve son kullanma tarihi saklanır ham numara hiçbir yerde tutulmaz.
</div>
</div>
</label>
</div>
)}
<div className="flex flex-col gap-2 pt-2 sm:flex-row">
<Button
type="button"
size="lg"
className="flex-1"
onClick={handleConfirm}
disabled={busy || !filled}
>
{confirming ? (
<>
<Loader2 className="size-4 animate-spin" />
Onaylanıyor...
</>
) : (
<>
<Lock className="size-4" />
Güvenli ödeme {trFmt.format(amount)}
</>
)}
</Button>
<Button
type="button"
size="lg"
variant="outline"
onClick={handleCancel}
disabled={busy}
>
{cancelling ? (
<Loader2 className="size-4 animate-spin" />
) : (
<X className="size-4" />
)}
Vazgeç
</Button>
</div>
{!filled && mode === "new" && (
<p className="text-muted-foreground text-xs">
Onay butonu etkin olması için tüm kart alanlarını doldurman gerekir. Test modu
herhangi bir 16 haneli numara çalışır.
</p>
)}
<Link
href="/settings/billing"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<ArrowLeft className="size-3" />
Plan & Faturalandırma'ya dön
</Link>
</div>
</div>
);
}
@@ -1,70 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
import { getPaymentByOrderId } from "@/lib/appwrite/subscription-queries";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { MockPaymentForm } from "./components/mock-payment-form";
export default async function MockCheckoutPage({
params,
}: {
params: Promise<{ orderId: string }>;
}) {
const { orderId } = await params;
const ctx = await requireTenant();
if (ctx.role !== "owner") redirect("/settings/billing");
const payment = await getPaymentByOrderId(ctx.tenantId, orderId);
if (!payment) redirect("/settings/billing");
if (payment.status === "success") redirect("/settings/billing?upgraded=1");
if (payment.status === "failed") redirect("/settings/billing?cancelled=1");
const plan = PLAN_CATALOG[payment.plan];
const savedCards = await listSavedCards(ctx.tenantId);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/settings/billing"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="size-3.5" />
Plan & Faturalandırma
</Link>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">Ödemeyi tamamla</h1>
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"
>
Mock Test
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Sipariş No: <span className="font-mono">{payment.orderId}</span> · Aşağıdaki kart
formunu doldur alanlar gerçek zamanlı olarak karta yansır.
</p>
</div>
<Card>
<CardContent className="py-6">
<MockPaymentForm
orderId={payment.orderId}
amount={payment.amount}
planName={plan.name}
planPeriod="30 gün"
savedCards={savedCards}
/>
</CardContent>
</Card>
</div>
);
}
@@ -1,47 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
interface BillingHistoryItem {
id: number
month: string
plan: string
amount: string
status: string
}
interface BillingHistoryCardProps {
history: BillingHistoryItem[]
}
export function BillingHistoryCard({ history }: BillingHistoryCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Billing History</CardTitle>
<CardDescription>
View your past invoices and payments.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{history.map((item, index) => (
<div key={item.id}>
<div className="flex items-center justify-between py-2">
<div>
<div className="font-medium">{item.month}</div>
<div className="text-sm text-muted-foreground">{item.plan}</div>
</div>
<div className="text-right">
<div className="font-medium">{item.amount}</div>
<Badge variant="secondary">{item.status}</Badge>
</div>
</div>
{index < history.length - 1 && <Separator />}
</div>
))}
</div>
</CardContent>
</Card>
)
}
@@ -1,71 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Crown, AlertTriangle } from "lucide-react"
interface CurrentPlan {
planName: string
price: string
nextBilling: string
status: string
daysUsed: number
totalDays: number
progressPercentage: number
remainingDays: number
needsAttention: boolean
attentionMessage: string
}
interface CurrentPlanCardProps {
plan: CurrentPlan
}
export function CurrentPlanCard({ plan }: CurrentPlanCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Current Plan</CardTitle>
<CardDescription>
You are currently on the {plan.planName}.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Crown className="h-5 w-5 text-yellow-500" />
<span className="font-semibold">{plan.planName}</span>
<Badge variant="secondary">{plan.status}</Badge>
</div>
<div className="text-right">
<div className="text-2xl font-bold">{plan.price}</div>
<div className="text-sm text-muted-foreground">Next billing: {plan.nextBilling}</div>
</div>
</div>
{plan.needsAttention && (
<Card className="border-neutral-200 bg-neutral-50 dark:border-neutral-600 dark:bg-neutral-800">
<CardContent>
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-neutral-600 mt-0.5 dark:text-neutral-400" />
<div className="space-y-1">
<p className="font-medium text-neutral-800 dark:text-neutral-400">We need your attention!</p>
<p className="text-sm text-neutral-700 dark:text-neutral-400">{plan.attentionMessage}</p>
</div>
</div>
{/* Progress Section */}
<div className="mt-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground font-medium">Days</span>
<span className="text-sm text-muted-foreground font-medium">{plan.daysUsed} of {plan.totalDays} Days</span>
</div>
<Progress value={plan.progressPercentage} className="h-2" />
<p className="text-xs text-muted-foreground">{plan.remainingDays} days remaining until your plan requires update</p>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)
}
@@ -1,23 +0,0 @@
[
{
"id": 1,
"month": "December 2024",
"plan": "Professional Plan",
"amount": "$79.00",
"status": "Paid"
},
{
"id": 2,
"month": "November 2024",
"plan": "Professional Plan",
"amount": "$79.00",
"status": "Paid"
},
{
"id": 3,
"month": "October 2024",
"plan": "Professional Plan",
"amount": "$79.00",
"status": "Paid"
}
]
@@ -1,12 +0,0 @@
{
"planName": "Professional Plan",
"price": "$79/month",
"nextBilling": "Aug 15, 2025",
"status": "Current",
"daysUsed": 18,
"totalDays": 30,
"progressPercentage": 60,
"remainingDays": 12,
"needsAttention": true,
"attentionMessage": "Your plan requires update"
}

Some files were not shown because too many files have changed in this diff Show More