diff --git a/app/(dashboard)/finance/actions.ts b/app/(dashboard)/finance/actions.ts new file mode 100644 index 0000000..196e5c2 --- /dev/null +++ b/app/(dashboard)/finance/actions.ts @@ -0,0 +1,122 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; + +const TRANSACTION_TYPES = ["income", "expense"] as const; +const PAYMENT_STATUSES = ["planned", "pending", "paid", "cancelled"] as const; + +function cleanText(value: FormDataEntryValue | null) { + const text = typeof value === "string" ? value.trim() : ""; + return text.length > 0 && text !== "__none" ? text : null; +} + +function readType(value: FormDataEntryValue | null) { + const type = typeof value === "string" ? value : "expense"; + return TRANSACTION_TYPES.includes(type as (typeof TRANSACTION_TYPES)[number]) + ? type + : "expense"; +} + +function readPaymentStatus(value: FormDataEntryValue | null) { + const status = typeof value === "string" ? value : "planned"; + return PAYMENT_STATUSES.includes(status as (typeof PAYMENT_STATUSES)[number]) + ? status + : "planned"; +} + +function readAmount(value: FormDataEntryValue | null) { + const amount = Number(typeof value === "string" ? value.replace(",", ".") : value); + return Number.isFinite(amount) && amount >= 0 ? amount : null; +} + +async function getCurrentUserId() { + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + throw new Error("Finans işlemi için giriş yapmış kullanıcı bulunamadı."); + } + + return { supabase, userId: user.id }; +} + +function readPayload(formData: FormData) { + return { + type: readType(formData.get("type")), + amount: readAmount(formData.get("amount")), + currency: cleanText(formData.get("currency")) || "USD", + transaction_date: cleanText(formData.get("transaction_date")) || new Date().toISOString().slice(0, 10), + category: cleanText(formData.get("category")), + payment_status: readPaymentStatus(formData.get("payment_status")), + client_id: cleanText(formData.get("client_id")), + project_id: cleanText(formData.get("project_id")), + description: cleanText(formData.get("description")), + }; +} + +export async function createFinanceTransactionRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const payload = readPayload(formData); + + if (payload.amount === null) { + throw new Error("Tutar zorunludur."); + } + + const { error } = await supabase.from("finance_transactions").insert({ + user_id: userId, + ...payload, + }); + + if (error) { + throw new Error(`Finans işlemi eklenemedi: ${error.message}`); + } + + revalidatePath("/finance"); +} + +export async function updateFinanceTransactionRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const id = cleanText(formData.get("id")); + const payload = readPayload(formData); + + if (!id || payload.amount === null) { + throw new Error("Finans işlemini güncellemek için kayıt kimliği ve tutar zorunludur."); + } + + const { error } = await supabase + .from("finance_transactions") + .update(payload) + .eq("id", id) + .eq("user_id", userId); + + if (error) { + throw new Error(`Finans işlemi güncellenemedi: ${error.message}`); + } + + revalidatePath("/finance"); +} + +export async function deleteFinanceTransactionRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const id = cleanText(formData.get("id")); + + if (!id) { + throw new Error("Silinecek finans işlemi bulunamadı."); + } + + const { error } = await supabase + .from("finance_transactions") + .delete() + .eq("id", id) + .eq("user_id", userId); + + if (error) { + throw new Error(`Finans işlemi silinemedi: ${error.message}`); + } + + revalidatePath("/finance"); +} diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx new file mode 100644 index 0000000..2865aa5 --- /dev/null +++ b/app/(dashboard)/finance/finance-client.tsx @@ -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 ( +
+
+
+
+ + Finans +
+
+

+ Finans işlemleri +

+

+ Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et. +

+
+
+ + +
+ +
+ + + + +
+ +
+ + +
+
+

İşlem listesi

+

+ {filteredTransactions.length} kayıt görüntüleniyor. +

+
+
+ setQuery(event.target.value)} + placeholder="Kategori, müşteri, proje veya açıklama ara" + className="sm:w-80" + /> + setMonthFilter(event.target.value)} + className="sm:w-44" + /> +
+
+ + {filteredTransactions.length > 0 ? ( +
+
+ İşlem + Tarih + Tutar + Durum + İşlem +
+
+ {filteredTransactions.map((transaction) => ( + + ))} +
+
+ ) : ( + + )} +
+
+ + + +
+

Gider kategorileri

+

Aylık gider dağılımı

+
+ {categoryBreakdown.length > 0 ? ( +
+ {categoryBreakdown.map((item) => ( +
+
+ {item.category} + {formatCurrency(item.amount)} +
+
+
+
+
+ ))} +
+ ) : ( +

Bu ay gider kaydı yok.

+ )} + + +
+
+ ); +} + +function TransactionRow({ + transaction, + clients, + projects, +}: { + transaction: FinanceTransactionItem; + clients: FinanceRelationOption[]; + projects: FinanceRelationOption[]; +}) { + const isIncome = transaction.type === "income"; + + return ( +
+
+
+ {isIncome ? : } +
+
+
+ {transaction.description || typeLabels[transaction.type]} +
+
+ {transaction.category || "Kategori yok"} · {transaction.projectName || transaction.clientName || "Bağlantı yok"} +
+
+
+
{formatDate(transaction.transaction_date)}
+
+ {isIncome ? "+" : "-"} + {formatCurrency(transaction.amount, transaction.currency)} +
+
+ + {paymentStatusLabels[transaction.payment_status]} + +
+
+ +
+ + +
+
+
+ ); +} + +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 ( + + + + + +
+ {transaction ? : null} + + {mode === "create" ? "Yeni finans işlemi" : "Finans işlemini düzenle"} + Gelir veya gider kaydını müşteri/proje bağlantısıyla kaydet. + +
+ +
+ + + +
+
+
+ ); +} + +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 ( +
+
+ + Gelir + Gider + + + Planlandı + Bekliyor + Ödendi + İptal edildi + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + {shouldLockClient ? : null} + +
+
+ + +
+
+
+ +