init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM - Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Same stack as isletmem-kovakcrm (shadcn/ui template base) - Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"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 }),
|
||||
}))
|
||||
@@ -0,0 +1,196 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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,54 @@
|
||||
import type { Metadata } from "next";
|
||||
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 (
|
||||
<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">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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
||||
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
||||
|
||||
export type ShellUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ShellCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "16rem",
|
||||
"--sidebar-width-icon": "3rem",
|
||||
"--header-height": "calc(var(--spacing) * 14)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||
>
|
||||
{config.side === "left" ? (
|
||||
<>
|
||||
<AppSidebar
|
||||
user={user}
|
||||
company={company}
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
<AppSidebar
|
||||
user={user}
|
||||
company={company}
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ThemeCustomizerTrigger onClick={() => setThemeCustomizerOpen(true)} />
|
||||
<ThemeCustomizer
|
||||
open={themeCustomizerOpen}
|
||||
onOpenChange={setThemeCustomizerOpen}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
|
||||
export const description = "An interactive area chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/customers">
|
||||
<UserPlus className="size-3.5" />
|
||||
Müşteri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/invoices">
|
||||
<Receipt className="size-3.5" />
|
||||
Fatura
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/calendar">
|
||||
<Calendar className="size-3.5" />
|
||||
Etkinlik
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<FilePlus className="size-3.5" />
|
||||
Görev
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <TrendingDown className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<TrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Technical Specifications Document v2.1",
|
||||
"type": "Technical Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Security Compliance Report Q4 2024",
|
||||
"type": "Compliance Document",
|
||||
"status": "Under Review",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Project Management Plan v3.0",
|
||||
"type": "Management Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Risk Assessment Matrix 2025",
|
||||
"type": "Risk Document",
|
||||
"status": "Draft",
|
||||
"target": "80%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Quality Assurance Protocol v1.5",
|
||||
"type": "QA Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Dr. Sarah Mitchell",
|
||||
"type": "Project Manager",
|
||||
"status": "Active",
|
||||
"target": "15 years",
|
||||
"limit": "20 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "James Thompson",
|
||||
"type": "Lead Engineer",
|
||||
"status": "Active",
|
||||
"target": "12 years",
|
||||
"limit": "15 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Maria Rodriguez",
|
||||
"type": "Security Specialist",
|
||||
"status": "Active",
|
||||
"target": "8 years",
|
||||
"limit": "10 years",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "David Chen",
|
||||
"type": "Systems Architect",
|
||||
"status": "Active",
|
||||
"target": "10 years",
|
||||
"limit": "12 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Lisa Johnson",
|
||||
"type": "Quality Assurance Lead",
|
||||
"status": "Active",
|
||||
"target": "6 years",
|
||||
"limit": "8 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Federal Communications Commission - Network Infrastructure Modernization",
|
||||
"type": "Government Contract",
|
||||
"status": "Completed",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Department of Defense - Cybersecurity Enhancement Program",
|
||||
"type": "Defense Contract",
|
||||
"status": "Completed",
|
||||
"target": "98%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "NASA - Satellite Communication System Upgrade",
|
||||
"type": "Space Technology",
|
||||
"status": "Completed",
|
||||
"target": "92%",
|
||||
"limit": "95%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Department of Homeland Security - Border Security Tech",
|
||||
"type": "Security Contract",
|
||||
"status": "In Progress",
|
||||
"target": "85%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "GSA - Cloud Infrastructure Migration",
|
||||
"type": "IT Services",
|
||||
"status": "Completed",
|
||||
"target": "96%",
|
||||
"limit": "98%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
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() {
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const data = await getDashboardData(ctx.tenantId, ctx.user.id);
|
||||
|
||||
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<p className="text-muted-foreground text-sm">{companyName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel bakış"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
İşletmenizin temel metriklerini ve son hareketleri buradan takip edin.
|
||||
</p>
|
||||
</div>
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<div className="@container/main space-y-6">
|
||||
<Metrics data={data.metrics} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
|
||||
<IncomeChart data={data.monthlyIncome} />
|
||||
<TopCustomers data={data.topCustomers} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
|
||||
<RecentTransactions data={data.recentTransactions} />
|
||||
<CustomerGrowth data={data.newCustomersMonthly} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof schema>
|
||||
@@ -0,0 +1,129 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "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 }
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type BankAccountRow = {
|
||||
id: string;
|
||||
bankName: string;
|
||||
accountName: string;
|
||||
iban: string;
|
||||
openingBalance: number;
|
||||
notes: string;
|
||||
archived: boolean;
|
||||
balance: number;
|
||||
scope: "company" | "personal";
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"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 aç
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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",
|
||||
"": "—",
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
"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 },
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"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">
|
||||
“Ödendi” 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"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" />
|
||||
Aç
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const company = {
|
||||
id: ctx.tenantId,
|
||||
name: ctx.settings?.companyName ?? "Çalışma alanı",
|
||||
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
|
||||
};
|
||||
const user = {
|
||||
id: ctx.user.id,
|
||||
name: ctx.user.name || ctx.user.email,
|
||||
email: ctx.user.email,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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" },
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"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";
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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]
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Mail } from "./components/mail";
|
||||
import { accounts, mails } from "./data";
|
||||
|
||||
export default function MailPage() {
|
||||
return <Mail accounts={accounts} mails={mails} navCollapsedSize={4} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,52 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,295 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 };
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateEmailAction } from "@/lib/appwrite/profile-actions";
|
||||
import { initialProfileState } from "@/lib/appwrite/profile-types";
|
||||
|
||||
export function EmailForm({ currentEmail }: { currentEmail: string }) {
|
||||
const [state, formAction, isPending] = useActionState(updateEmailAction, initialProfileState);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Email güncellendi.");
|
||||
// Clear password field after success
|
||||
formRef.current?.reset();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email adresi</CardTitle>
|
||||
<CardDescription>
|
||||
Email değiştirmek için mevcut şifrenizi de girin. Yeni email ile giriş yapmaya devam edersiniz.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Yeni email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={currentEmail}
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.email && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email-password">Şifre (doğrulama)</Label>
|
||||
<Input
|
||||
id="email-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.password && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
Email'i güncelle
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateNameAction } from "@/lib/appwrite/profile-actions";
|
||||
import { initialProfileState } from "@/lib/appwrite/profile-types";
|
||||
|
||||
export function NameForm({ currentName }: { currentName: string }) {
|
||||
const [state, formAction, isPending] = useActionState(updateNameAction, initialProfileState);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) toast.success("İsim güncellendi.");
|
||||
else if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Görünür isim</CardTitle>
|
||||
<CardDescription>
|
||||
Header'da, davetlerde ve takım listesinde görünecek isim.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction} className="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">İsim</Label>
|
||||
<Input id="name" name="name" defaultValue={currentName} required maxLength={128} />
|
||||
{state.fieldErrors?.name && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef } from "react";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updatePasswordAction } from "@/lib/appwrite/profile-actions";
|
||||
import { initialProfileState } from "@/lib/appwrite/profile-types";
|
||||
|
||||
export function PasswordForm() {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
updatePasswordAction,
|
||||
initialProfileState,
|
||||
);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Şifre değiştirildi.");
|
||||
formRef.current?.reset();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Şifre</CardTitle>
|
||||
<CardDescription>
|
||||
Şifrenizi değiştirmek için mevcut şifrenizi ve yeni şifreyi iki kez girin.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="oldPassword">Mevcut şifre</Label>
|
||||
<Input
|
||||
id="oldPassword"
|
||||
name="oldPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.oldPassword && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.oldPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPassword">Yeni şifre</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.newPassword && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.newPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmPassword">Yeni şifre (tekrar)</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.confirmPassword && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-3 flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Güncelleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="size-4" />
|
||||
Şifreyi değiştir
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { NameForm } from "./components/name-form";
|
||||
import { EmailForm } from "./components/email-form";
|
||||
import { PasswordForm } from "./components/password-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Profil",
|
||||
};
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/sign-in");
|
||||
|
||||
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">Profil ayarları</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Hesap bilgilerinizi ve şifrenizi buradan yönetin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Hesap bilgileri</CardTitle>
|
||||
<CardDescription>Kayıt tarihi ve hesap durumu</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs uppercase">Hesap ID</dt>
|
||||
<dd className="mt-1 font-mono text-xs">{user.$id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs uppercase">Kayıt tarihi</dt>
|
||||
<dd className="mt-1">{formatDateTime(user.registration)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs uppercase">Email doğrulanmış</dt>
|
||||
<dd className="mt-1">{user.emailVerification ? "Evet" : "Hayır"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs uppercase">İki faktör (2FA)</dt>
|
||||
<dd className="mt-1">{user.mfa ? "Açık" : "Kapalı"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NameForm currentName={user.name || ""} />
|
||||
<EmailForm currentEmail={user.email} />
|
||||
<PasswordForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user