feat(dashboard): implement daily journal functionality with CRUD operations

- Added actions for creating, updating, and deleting daily log records in `actions.ts`.
- Introduced `journal-client.tsx` for rendering the journal UI, including log entries and statistics.
- Refactored `page.tsx` to fetch user-specific daily logs from Supabase and display them using the new client component.
- Implemented form handling for daily log creation and updates, including mood and energy scoring.
This commit is contained in:
Poyraz Avsever
2026-06-04 19:53:51 +03:00
parent 7ee3104158
commit dd5ae0617e
6 changed files with 1421 additions and 676 deletions
+570
View File
@@ -0,0 +1,570 @@
"use client";
import {
createFinanceTransactionRecord,
deleteFinanceTransactionRecord,
updateFinanceTransactionRecord,
} from "@/app/(dashboard)/finance/actions";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "poyraz-ui/molecules";
import {
ArrowDownRight,
ArrowUpRight,
Pencil,
Plus,
Trash2,
Wallet,
} from "lucide-react";
import { useMemo, useState } from "react";
export type FinanceRelationOption = {
id: string;
name: string;
client_id?: string | null;
};
export type FinanceTransactionItem = {
id: string;
type: "income" | "expense";
amount: number;
currency: string;
transaction_date: string;
category: string | null;
payment_status: "planned" | "pending" | "paid" | "cancelled";
client_id: string | null;
project_id: string | null;
clientName: string | null;
projectName: string | null;
description: string | null;
};
const typeLabels = {
income: "Gelir",
expense: "Gider",
};
const paymentStatusLabels = {
planned: "Planlandı",
pending: "Bekliyor",
paid: "Ödendi",
cancelled: "İptal edildi",
};
const paymentStatusClasses = {
planned: "border-blue-200 bg-blue-50 text-blue-700",
pending: "border-amber-200 bg-amber-50 text-amber-700",
paid: "border-emerald-200 bg-emerald-50 text-emerald-700",
cancelled: "border-zinc-200 bg-zinc-50 text-zinc-700",
};
const currencyOptions = [
{ value: "USD", label: "Dolar (USD)" },
{ value: "EUR", label: "Euro (EUR)" },
{ value: "TRY", label: "Türk lirası (TRY)" },
{ value: "GBP", label: "Sterlin (GBP)" },
{ value: "CAD", label: "Kanada doları (CAD)" },
{ value: "AUD", label: "Avustralya doları (AUD)" },
];
type FinanceClientProps = {
transactions: FinanceTransactionItem[];
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
};
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
const [query, setQuery] = useState("");
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
const normalizedQuery = query.trim().toLowerCase();
const filteredByMonth = transactions.filter((transaction) =>
transaction.transaction_date.startsWith(monthFilter),
);
const filteredTransactions = normalizedQuery
? filteredByMonth.filter((transaction) =>
[
transaction.description,
transaction.category,
transaction.clientName,
transaction.projectName,
typeLabels[transaction.type],
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
)
: filteredByMonth;
const summary = useMemo(() => calculateSummary(filteredByMonth), [filteredByMonth]);
const categoryBreakdown = useMemo(() => calculateExpenseCategories(filteredByMonth), [filteredByMonth]);
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Wallet className="h-4 w-4" />
Finans
</div>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Finans işlemleri
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et.
</p>
</div>
</div>
<FinanceDialog mode="create" clients={clients} projects={projects} />
</div>
<div className="grid gap-3 md:grid-cols-4">
<StatCard label="Aylık gelir" value={formatCurrency(summary.income)} tone="green" />
<StatCard label="Aylık gider" value={formatCurrency(summary.expense)} tone="rose" />
<StatCard label="Net kazanç" value={formatCurrency(summary.net)} tone="primary" />
<StatCard label="Bekleyen" value={formatCurrency(summary.pending)} tone="amber" />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">İşlem listesi</h2>
<p className="text-sm text-muted-foreground">
{filteredTransactions.length} kayıt görüntüleniyor.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Kategori, müşteri, proje veya açıklama ara"
className="sm:w-80"
/>
<Input
type="month"
value={monthFilter}
onChange={(event) => setMonthFilter(event.target.value)}
className="sm:w-44"
/>
</div>
</div>
{filteredTransactions.length > 0 ? (
<div className="overflow-hidden rounded-sm border border-border">
<div className="hidden grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr_1fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
<span>İşlem</span>
<span>Tarih</span>
<span>Tutar</span>
<span>Durum</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y divide-border">
{filteredTransactions.map((transaction) => (
<TransactionRow
key={transaction.id}
transaction={transaction}
clients={clients}
projects={projects}
/>
))}
</div>
</div>
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 p-4">
<div>
<h2 className="text-base font-semibold text-foreground">Gider kategorileri</h2>
<p className="text-sm text-muted-foreground">Aylık gider dağılımı</p>
</div>
{categoryBreakdown.length > 0 ? (
<div className="space-y-3">
{categoryBreakdown.map((item) => (
<div key={item.category} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{item.category}</span>
<span className="font-medium text-foreground">{formatCurrency(item.amount)}</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${item.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Bu ay gider kaydı yok.</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function TransactionRow({
transaction,
clients,
projects,
}: {
transaction: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
}) {
const isIncome = transaction.type === "income";
return (
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr_1fr] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<div className={isIncome ? "rounded-sm bg-emerald-50 p-2 text-emerald-700" : "rounded-sm bg-rose-50 p-2 text-rose-700"}>
{isIncome ? <ArrowUpRight className="h-4 w-4" /> : <ArrowDownRight className="h-4 w-4" />}
</div>
<div className="min-w-0">
<div className="font-medium text-foreground">
{transaction.description || typeLabels[transaction.type]}
</div>
<div className="truncate text-sm text-muted-foreground">
{transaction.category || "Kategori yok"} · {transaction.projectName || transaction.clientName || "Bağlantı yok"}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">{formatDate(transaction.transaction_date)}</div>
<div className={isIncome ? "font-semibold text-emerald-700" : "font-semibold text-rose-700"}>
{isIncome ? "+" : "-"}
{formatCurrency(transaction.amount, transaction.currency)}
</div>
<div>
<Badge className={paymentStatusClasses[transaction.payment_status]}>
{paymentStatusLabels[transaction.payment_status]}
</Badge>
</div>
<div className="flex justify-start gap-2 lg:justify-end">
<FinanceDialog mode="edit" transaction={transaction} clients={clients} projects={projects} />
<form action={deleteFinanceTransactionRecord}>
<input type="hidden" name="id" value={transaction.id} />
<Button type="submit" variant="outline" className="h-9 gap-2 text-rose-600">
<Trash2 className="h-4 w-4" />
Sil
</Button>
</form>
</div>
</div>
);
}
function FinanceDialog({
mode,
transaction,
clients,
projects,
}: {
mode: "create" | "edit";
transaction?: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
}) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const action = mode === "create" ? createFinanceTransactionRecord : updateFinanceTransactionRecord;
async function handleSubmit(formData: FormData) {
setIsSubmitting(true);
try {
await action(formData);
setOpen(false);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={mode === "create" ? "default" : "outline"} className="h-9 gap-2">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "İşlem ekle" : "Düzenle"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[min(680px,calc(100dvh-6rem))] overflow-hidden sm:max-w-xl">
<form action={handleSubmit} className="flex max-h-[min(640px,calc(100dvh-9rem))] flex-col">
{transaction ? <input type="hidden" name="id" value={transaction.id} /> : null}
<DialogHeader className="shrink-0 pb-5">
<DialogTitle>{mode === "create" ? "Yeni finans işlemi" : "Finans işlemini düzenle"}</DialogTitle>
<DialogDescription>Gelir veya gider kaydını müşteri/proje bağlantısıyla kaydet.</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto pr-2">
<FinanceFormFields transaction={transaction} clients={clients} projects={projects} />
</div>
<DialogFooter className="shrink-0 border-t border-border pt-5">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Kaydediliyor" : mode === "create" ? "İşlemi ekle" : "Değişiklikleri kaydet"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function FinanceFormFields({
transaction,
clients,
projects,
}: {
transaction?: FinanceTransactionItem;
clients: FinanceRelationOption[];
projects: FinanceRelationOption[];
}) {
const [clientId, setClientId] = useState(transaction?.client_id || "__none");
const [projectId, setProjectId] = useState(transaction?.project_id || "__none");
const selectedProject =
projectId === "__none" ? null : projects.find((project) => project.id === projectId) || null;
const shouldLockClient = Boolean(selectedProject);
const filteredProjects =
clientId === "__none" || shouldLockClient
? projects
: projects.filter((project) => project.client_id === clientId);
const currencyValue = transaction?.currency || "USD";
const hasCustomCurrency = !currencyOptions.some((currency) => currency.value === currencyValue);
function handleClientChange(nextClientId: string) {
setClientId(nextClientId);
if (
projectId !== "__none" &&
nextClientId !== "__none" &&
!projects.some((project) => project.id === projectId && project.client_id === nextClientId)
) {
setProjectId("__none");
}
}
function handleProjectChange(nextProjectId: string) {
setProjectId(nextProjectId);
if (nextProjectId === "__none") {
return;
}
const nextProject = projects.find((project) => project.id === nextProjectId);
setClientId(nextProject?.client_id || "__none");
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<SelectField name="type" label="Tip" defaultValue={transaction?.type || "income"}>
<SelectItem value="income">Gelir</SelectItem>
<SelectItem value="expense">Gider</SelectItem>
</SelectField>
<SelectField name="payment_status" label="Ödeme durumu" defaultValue={transaction?.payment_status || "planned"}>
<SelectItem value="planned">Planlandı</SelectItem>
<SelectItem value="pending">Bekliyor</SelectItem>
<SelectItem value="paid">Ödendi</SelectItem>
<SelectItem value="cancelled">İptal edildi</SelectItem>
</SelectField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>Tutar</Label>
<Input name="amount" type="number" min="0" step="0.01" required defaultValue={transaction?.amount ?? ""} />
</div>
<div className="grid gap-2">
<Label>Para birimi</Label>
<Select name="currency" defaultValue={currencyValue}>
<SelectTrigger>
<SelectValue placeholder="Para birimi seç" />
</SelectTrigger>
<SelectContent>
{currencyOptions.map((currency) => (
<SelectItem key={currency.value} value={currency.value}>
{currency.label}
</SelectItem>
))}
{hasCustomCurrency ? (
<SelectItem value={currencyValue}>{currencyValue}</SelectItem>
) : null}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Tarih</Label>
<Input name="transaction_date" type="date" defaultValue={transaction?.transaction_date || new Date().toISOString().slice(0, 10)} />
</div>
</div>
<div className="grid gap-2">
<Label>Kategori</Label>
<Input name="category" defaultValue={transaction?.category || ""} placeholder="Örn. Yazılım, müşteri ödemesi, vergi" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Müşteri</Label>
{shouldLockClient ? <input type="hidden" name="client_id" value={clientId} /> : null}
<Select
name="client_id"
value={clientId}
onValueChange={handleClientChange}
disabled={shouldLockClient}
>
<SelectTrigger>
<SelectValue placeholder="Müşteri seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">Müşteri yok</SelectItem>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Proje</Label>
<Select name="project_id" value={projectId} onValueChange={handleProjectChange}>
<SelectTrigger>
<SelectValue placeholder="Proje seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">Proje yok</SelectItem>
{filteredProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label>Açıklama</Label>
<Textarea name="description" defaultValue={transaction?.description || ""} rows={3} />
</div>
</div>
);
}
function SelectField({ name, label, defaultValue, children }: { name: string; label: string; defaultValue: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<Select name={name} defaultValue={defaultValue}>
<SelectTrigger><SelectValue placeholder={`${label} seç`} /></SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
</div>
);
}
function StatCard({ label, value, tone }: { label: string; value: string; tone: "green" | "rose" | "primary" | "amber" }) {
const toneClass = {
green: "bg-emerald-50 text-emerald-700",
rose: "bg-rose-50 text-rose-700",
primary: "bg-primary/10 text-primary",
amber: "bg-amber-50 text-amber-700",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${toneClass}`}>
<Wallet className="h-5 w-5" />
</div>
</CardContent>
</Card>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
<Wallet className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold text-foreground">
{hasQuery ? "Aramana uygun işlem yok" : "Henüz finans işlemi eklenmedi"}
</h3>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{hasQuery
? "Arama metnini sadeleştirerek tekrar deneyebilirsin."
: "İlk gelir veya gider kaydını ekleyerek aylık finans özetini oluşturmaya başlayabilirsin."}
</p>
</div>
);
}
function calculateSummary(transactions: FinanceTransactionItem[]) {
return transactions.reduce(
(summary, transaction) => {
if (transaction.type === "income" && transaction.payment_status === "paid") {
summary.income += transaction.amount;
}
if (transaction.type === "expense" && transaction.payment_status === "paid") {
summary.expense += transaction.amount;
}
if (transaction.payment_status === "pending" || transaction.payment_status === "planned") {
summary.pending += transaction.amount;
}
summary.net = summary.income - summary.expense;
return summary;
},
{ income: 0, expense: 0, net: 0, pending: 0 },
);
}
function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
const totals = new Map<string, number>();
for (const transaction of transactions) {
if (transaction.type !== "expense") continue;
const category = transaction.category || "Kategori yok";
totals.set(category, (totals.get(category) || 0) + transaction.amount);
}
const total = Array.from(totals.values()).reduce((sum, value) => sum + value, 0);
return Array.from(totals.entries())
.map(([category, amount]) => ({
category,
amount,
percent: total ? Math.round((amount / total) * 100) : 0,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 6);
}
function formatCurrency(value: number, currency = "USD") {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(`${value}T00:00:00`));
}