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:
@@ -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");
|
||||||
|
}
|
||||||
@@ -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`));
|
||||||
|
}
|
||||||
@@ -1,404 +1,93 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Plus, Search, TrendingUp, TrendingDown, DollarSign,
|
|
||||||
CreditCard, ArrowUpRight, ArrowDownRight, MoreHorizontal,
|
|
||||||
Calendar, FileText, Download, PieChart as PieChartIcon,
|
|
||||||
Wallet, Brain, ArrowRight, X, Check, Activity,
|
|
||||||
Briefcase, Landmark, Receipt, Percent, Target, Filter
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Area, AreaChart, Bar, BarChart, CartesianGrid,
|
FinanceClient,
|
||||||
ResponsiveContainer, Tooltip, XAxis, YAxis, Cell,
|
type FinanceRelationOption,
|
||||||
Pie, PieChart
|
type FinanceTransactionItem,
|
||||||
} from "recharts";
|
} from "@/app/(dashboard)/finance/finance-client";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
// Mock Data
|
type FinanceRow = {
|
||||||
const cashflowData = [
|
id: string;
|
||||||
{ month: "Jan", income: 12500, expense: 8400, predicted: 13000 },
|
type: "income" | "expense";
|
||||||
{ month: "Feb", income: 14200, expense: 9100, predicted: 14500 },
|
amount: number | string;
|
||||||
{ month: "Mar", income: 13800, expense: 8800, predicted: 14000 },
|
currency: string;
|
||||||
{ month: "Apr", income: 15600, expense: 10200, predicted: 16000 },
|
transaction_date: string;
|
||||||
{ month: "May", income: 18400, expense: 11500, predicted: 19500 },
|
category: string | null;
|
||||||
{ month: "Jun", income: 21000, expense: 12000, predicted: 22000 },
|
payment_status: "planned" | "pending" | "paid" | "cancelled";
|
||||||
];
|
client_id: string | null;
|
||||||
|
project_id: string | null;
|
||||||
|
description: string | null;
|
||||||
|
clients: { name: string } | { name: string }[] | null;
|
||||||
|
projects: { name: string } | { name: string }[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
const expensesByCategory = [
|
export default async function FinancePage() {
|
||||||
{ name: "Software", value: 3400, color: "#6C5BB0" },
|
const supabase = await createClient();
|
||||||
{ name: "Rent", value: 2500, color: "#a798e8" },
|
const {
|
||||||
{ name: "Marketing", value: 1800, color: "#10b981" },
|
data: { user },
|
||||||
{ name: "Taxes", value: 4200, color: "#3b82f6" },
|
} = await supabase.auth.getUser();
|
||||||
{ name: "Travel", value: 1200, color: "#f59e0b" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const transactions = [
|
if (!user) {
|
||||||
{ id: 1, name: "Stripe Subscription", date: "May 15", amount: "+$4,200", status: "Success", type: "Income", category: "Direct Sales", project: "Cognis Mobile" },
|
return null;
|
||||||
{ id: 2, name: "Amazon Web Services", date: "May 12", amount: "-$840", status: "Pending", type: "Expense", category: "Cloud Infra", project: "Infrastructure" },
|
}
|
||||||
{ id: 3, name: "Office Rent Q3", date: "May 10", amount: "-$2,500", status: "Success", type: "Expense", category: "Overhead", project: "General" },
|
|
||||||
{ id: 4, name: "Client: Marketing Site", date: "May 08", amount: "+$2,800", status: "Success", type: "Income", category: "Freelance", project: "Marketing Site" },
|
|
||||||
{ id: 5, name: "GitHub Enterprise", date: "May 05", amount: "-$120", status: "Success", type: "Expense", category: "Software", project: "Infrastructure" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FinancePage() {
|
const [{ data: financeRows }, { data: clientRows }, { data: projectRows }] =
|
||||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
await Promise.all([
|
||||||
const [selectedTransaction, setSelectedTransaction] = useState<any>(null);
|
supabase
|
||||||
|
.from("finance_transactions")
|
||||||
|
.select("id, type, amount, currency, transaction_date, category, payment_status, client_id, project_id, description, clients(name), projects(name)")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.order("transaction_date", { ascending: false }),
|
||||||
|
supabase
|
||||||
|
.from("clients")
|
||||||
|
.select("id, name")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.neq("status", "archived")
|
||||||
|
.order("name", { ascending: true }),
|
||||||
|
supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("id, name, client_id")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.neq("status", "cancelled")
|
||||||
|
.order("name", { ascending: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const transactions: FinanceTransactionItem[] = ((financeRows || []) as unknown as FinanceRow[]).map((transaction) => ({
|
||||||
|
id: transaction.id,
|
||||||
|
type: normalizeType(transaction.type),
|
||||||
|
amount: Number(transaction.amount),
|
||||||
|
currency: transaction.currency,
|
||||||
|
transaction_date: transaction.transaction_date,
|
||||||
|
category: transaction.category,
|
||||||
|
payment_status: normalizePaymentStatus(transaction.payment_status),
|
||||||
|
client_id: transaction.client_id,
|
||||||
|
project_id: transaction.project_id,
|
||||||
|
clientName: getRelationName(transaction.clients),
|
||||||
|
projectName: getRelationName(transaction.projects),
|
||||||
|
description: transaction.description,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans space-y-6 pb-12 relative">
|
<FinanceClient
|
||||||
|
transactions={transactions}
|
||||||
{/* Top Header */}
|
clients={(clientRows || []) as FinanceRelationOption[]}
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
|
projects={(projectRows || []) as FinanceRelationOption[]}
|
||||||
<div className="flex items-center gap-4">
|
/>
|
||||||
<h1 className="text-lg font-medium text-muted-foreground">
|
|
||||||
<span className="text-foreground">Business</span> / Financials
|
|
||||||
</h1>
|
|
||||||
<div className="h-4 w-px bg-white/10" />
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-emerald-500 bg-emerald-500/5 px-3 py-1 rounded-sm border border-emerald-500/10">
|
|
||||||
<Check className="h-3 w-3" /> ALL SYSTEMS HEALTHY
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInvoiceModal(true)}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-5 py-2 rounded-sm text-xs font-black tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-primary/30 active:scale-95"
|
|
||||||
>
|
|
||||||
<Receipt className="h-4 w-4" />
|
|
||||||
CREATE INVOICE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 shrink-0">
|
|
||||||
<FinanceKpiCard label="Total Revenue" value="$42,850" change="+18%" trend="up" icon={DollarSign} />
|
|
||||||
<FinanceKpiCard label="Tax Provision" value="$8,400" change="+12%" trend="up" icon={Landmark} />
|
|
||||||
<FinanceKpiCard label="Operating Costs" value="$12,400" change="-5%" trend="down" icon={TrendingDown} />
|
|
||||||
<FinanceKpiCard label="Projected Net" value="$30,450" change="+24%" trend="up" icon={Wallet} color="primary" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Analysis Area */}
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 flex-1 min-h-[400px]">
|
|
||||||
|
|
||||||
{/* Cashflow Chart */}
|
|
||||||
<div className="xl:col-span-2 bg-[#0A0710] border border-white/5 rounded-sm p-8 flex flex-col relative overflow-hidden group shadow-2xl">
|
|
||||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
|
|
||||||
<TrendingUp className="h-48 w-48 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-start mb-10 relative z-10">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-black mb-1 flex items-center gap-3">
|
|
||||||
Intelligent Cashflow
|
|
||||||
<span className="text-[10px] font-black bg-primary/20 text-primary px-3 py-1 rounded-sm uppercase tracking-[0.2em] animate-pulse">AI Forecasting</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md">Real-time revenue monitoring compared with deep-learning projections for the next quarter.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-6 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-primary" /> Income</div>
|
|
||||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-rose-500" /> Expense</div>
|
|
||||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full border-2 border-dashed border-primary" /> Forecast</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 w-full min-h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={cashflowData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#6C5BB0" stopOpacity={0.4}/>
|
|
||||||
<stop offset="95%" stopColor="#6C5BB0" stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff05" />
|
|
||||||
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#8F89A5', fontWeight: 600 }} dy={10} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#8F89A5', fontWeight: 600 }} dx={-10} />
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ stroke: '#6C5BB0', strokeWidth: 1 }}
|
|
||||||
contentStyle={{ backgroundColor: "#150F1D", border: "1px solid rgba(255,255,255,0.05)", borderRadius: "4px", fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="income" stroke="#6C5BB0" strokeWidth={4} fillOpacity={1} fill="url(#colorIncome)" />
|
|
||||||
<Area type="monotone" dataKey="expense" stroke="#f43f5e" strokeWidth={2} fill="transparent" strokeDasharray="4 4" />
|
|
||||||
<Area type="monotone" dataKey="predicted" stroke="#6C5BB0" strokeWidth={1} fill="transparent" strokeDasharray="10 10" opacity={0.5} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expense Breakdown & AI Insights */}
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm p-8 flex flex-col shadow-2xl">
|
|
||||||
<h3 className="text-[11px] font-black uppercase tracking-[0.2em] text-muted-foreground mb-8">Category Allocation</h3>
|
|
||||||
<div className="flex-1 w-full min-h-[180px] flex items-center justify-center">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={expensesByCategory}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={55}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
stroke="none"
|
|
||||||
>
|
|
||||||
{expensesByCategory.map((entry, index) => (
|
|
||||||
<Cell key={index} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: "#150F1D", border: "none" }} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 space-y-3">
|
|
||||||
{expensesByCategory.slice(0, 3).map(ex => (
|
|
||||||
<div key={ex.name} className="flex justify-between items-center text-[10px] font-black uppercase tracking-widest">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: ex.color }} />
|
|
||||||
<span className="text-muted-foreground">{ex.name}</span>
|
|
||||||
</div>
|
|
||||||
<span>${ex.value.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-primary/5 border border-primary/20 rounded-sm p-8 flex-1 flex flex-col justify-between relative overflow-hidden group">
|
|
||||||
<div className="absolute -right-4 -top-4 opacity-5 rotate-12 transition-transform group-hover:scale-110">
|
|
||||||
<Brain className="h-24 w-24 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-primary/20 rounded-sm">
|
|
||||||
<Percent className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-[11px] font-black uppercase tracking-[0.2em] text-primary">Strategic Advantage</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-bold leading-relaxed text-foreground/90 italic">
|
|
||||||
"Your project-specific ROI is 18% higher when tasks are completed in the morning focus block. Strategic reinvestment of $5k suggested for Q4."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="text-[10px] font-black text-primary uppercase tracking-[0.2em] flex items-center gap-2 group/btn border-b border-primary/20 pb-1 self-start hover:border-primary transition-all">
|
|
||||||
OPTIMIZE SPENDING <ArrowRight className="h-3 w-3 group-hover/btn:translate-x-1 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transactions Ledger */}
|
|
||||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm overflow-hidden flex flex-col h-[450px] shadow-2xl">
|
|
||||||
<div className="flex justify-between items-center p-8 border-b border-white/5 bg-[#0F0B15]/40 backdrop-blur-sm">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-black uppercase tracking-[0.3em] text-muted-foreground">Strategic Ledger</h3>
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 uppercase font-bold tracking-widest">Showing last 24 transactions across 5 projects</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
|
|
||||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground">FILTER</span>
|
|
||||||
</div>
|
|
||||||
<button className="text-[10px] font-black text-primary uppercase tracking-[0.2em] bg-primary/10 px-4 py-2 rounded-sm hover:bg-primary/20 transition-all">Full Report</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto tiny-scrollbar">
|
|
||||||
<div className="divide-y divide-white/5">
|
|
||||||
{transactions.map(t => (
|
|
||||||
<div key={t.id} onClick={() => setSelectedTransaction(t)} className="grid grid-cols-12 gap-4 p-6 items-center hover:bg-white/[0.02] transition-all group cursor-pointer border-l-2 border-transparent hover:border-primary">
|
|
||||||
<div className="col-span-1 flex justify-center">
|
|
||||||
<div className={`p-3 rounded-sm ${t.type === 'Income' ? 'bg-emerald-500/10 text-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.1)]' : 'bg-rose-500/10 text-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.1)]'}`}>
|
|
||||||
{t.type === 'Income' ? <ArrowUpRight className="h-5 w-5" /> : <ArrowDownRight className="h-5 w-5" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-5">
|
|
||||||
<div className="text-sm font-black group-hover:text-primary transition-colors">{t.name}</div>
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
|
||||||
<span className="text-[10px] text-muted-foreground uppercase font-black tracking-widest">{t.category}</span>
|
|
||||||
<span className="text-[10px] text-primary font-bold bg-primary/5 px-2 py-0.5 rounded-sm">{t.project}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-xs text-muted-foreground font-black uppercase tracking-tighter">{t.date}</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className={`text-sm font-black tracking-tighter ${t.type === 'Income' ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
||||||
{t.amount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><MoreHorizontal className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoice Editor Modal */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showInvoiceModal && (
|
|
||||||
<>
|
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowInvoiceModal(false)} className="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100]" />
|
|
||||||
<motion.div initial={{ scale: 0.9, opacity: 0, y: 20 }} animate={{ scale: 1, opacity: 1, y: 0 }} exit={{ scale: 0.9, opacity: 0, y: 20 }} className="fixed inset-0 m-auto w-full max-w-3xl h-fit max-h-[90vh] bg-[#0A0710] border border-white/10 z-[101] shadow-2xl flex flex-col rounded-sm overflow-hidden">
|
|
||||||
<div className="p-10 border-b border-white/5 flex items-center justify-between bg-primary/10 relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_30%_-20%,rgba(108,91,176,0.15),transparent)] pointer-events-none" />
|
|
||||||
<div className="flex items-center gap-4 relative z-10">
|
|
||||||
<div className="p-3 bg-primary rounded-sm shadow-xl shadow-primary/20"><Receipt className="h-6 w-6 text-primary-foreground" /></div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-black uppercase tracking-tighter">Strategic Invoice</h2>
|
|
||||||
<p className="text-[10px] font-black text-primary tracking-[0.3em] uppercase">AI-Optimized Billing Engine</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowInvoiceModal(false)} className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors relative z-10"><X className="h-7 w-7" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-10 space-y-8 overflow-y-auto tiny-scrollbar">
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Select Client</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input type="text" placeholder="Start typing client name..." className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-5 py-4 text-sm font-bold outline-none focus:border-primary/50 transition-all" />
|
|
||||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Project Association</label>
|
|
||||||
<select className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-5 py-4 text-sm font-bold outline-none focus:border-primary/50 transition-all appearance-none">
|
|
||||||
<option className="bg-[#0A0710]">Marketing Site Redesign</option>
|
|
||||||
<option className="bg-[#0A0710]">Infrastructure Scale</option>
|
|
||||||
<option className="bg-[#0A0710]">Cognis Mobile Dev</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-primary">Billable Items</h3>
|
|
||||||
<button className="text-[10px] font-black text-muted-foreground hover:text-primary transition-colors flex items-center gap-2">
|
|
||||||
<Plus className="h-3 w-3" /> ADD CUSTOM LINE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<InvoiceLineItem title="Phase 1: Design Strategy" rate="$120/hr" hours="24h" total="$2,880" />
|
|
||||||
<InvoiceLineItem title="Phase 2: Core Development" rate="$150/hr" hours="12h" total="$1,800" />
|
|
||||||
<div className="p-4 rounded-sm border border-dashed border-white/10 flex items-center justify-center text-[10px] font-bold text-muted-foreground hover:bg-white/5 transition-all cursor-pointer">
|
|
||||||
AI SUGGESTION: ADD "QA & TESTING" (8H)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-8 border-t border-white/10 flex flex-col items-end gap-3">
|
|
||||||
<div className="flex gap-16 text-sm">
|
|
||||||
<span className="text-muted-foreground font-black uppercase tracking-widest text-[10px] mt-1">Subtotal</span>
|
|
||||||
<span className="font-black text-lg">$4,680.00</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-16 text-sm">
|
|
||||||
<span className="text-muted-foreground font-black uppercase tracking-widest text-[10px] mt-1">Tax (18%)</span>
|
|
||||||
<span className="font-black text-lg">$842.40</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-16 text-2xl font-black mt-4 pt-4 border-t border-primary/20 w-full justify-end">
|
|
||||||
<span className="text-primary uppercase tracking-[0.4em] text-[10px] mt-3">Grand Total</span>
|
|
||||||
<span className="text-primary text-4xl tracking-tighter">$5,522.40</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-10 border-t border-white/5 flex gap-6 bg-[#0F0B15]/60">
|
|
||||||
<button className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground py-5 rounded-sm text-[11px] font-black uppercase tracking-[0.3em] shadow-2xl shadow-primary/40 transition-all active:scale-[0.98] flex items-center justify-center gap-3">
|
|
||||||
<Download className="h-5 w-5" /> GENERATE & SEND
|
|
||||||
</button>
|
|
||||||
<button className="px-10 border border-white/10 rounded-sm text-[11px] font-black uppercase tracking-[0.2em] text-muted-foreground hover:bg-white/5 transition-all">
|
|
||||||
SAVE AS DRAFT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Transaction Detail Sheet */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedTransaction && (
|
|
||||||
<>
|
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setSelectedTransaction(null)} className="fixed inset-0 bg-black/80 backdrop-blur-md z-[100]" />
|
|
||||||
<motion.div initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="fixed top-0 right-0 h-full w-full max-w-lg bg-[#0A0710] border-l border-white/5 z-[101] shadow-2xl flex flex-col p-10 space-y-12">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className={`p-4 rounded-sm ${selectedTransaction.type === 'Income' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500'}`}>
|
|
||||||
{selectedTransaction.type === 'Income' ? <ArrowUpRight className="h-8 w-8" /> : <ArrowDownRight className="h-8 w-8" />}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedTransaction(null)} className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><X className="h-7 w-7" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-3xl font-black tracking-tighter">{selectedTransaction.name}</h2>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className={`text-xl font-black ${selectedTransaction.type === 'Income' ? 'text-emerald-400' : 'text-rose-400'}`}>{selectedTransaction.amount}</span>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest bg-white/5 px-2 py-1 rounded-sm text-muted-foreground border border-white/5">{selectedTransaction.status}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 pt-6 border-t border-white/5">
|
|
||||||
<DetailMeta label="TRANSACTION DATE" value={selectedTransaction.date} />
|
|
||||||
<DetailMeta label="STRATEGIC CATEGORY" value={selectedTransaction.category} />
|
|
||||||
<DetailMeta label="PROJECT LINK" value={selectedTransaction.project} isPrimary />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-sm border border-primary/20 bg-primary/5 p-6 space-y-4">
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-primary flex items-center gap-2">
|
|
||||||
<Brain className="h-3.5 w-3.5" /> Financial Insight
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed italic">
|
|
||||||
This {selectedTransaction.type.toLowerCase()} was processed via Stripe and linked to your **{selectedTransaction.project}** deliverables. Tax obligations have been pre-calculated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="w-full bg-white/5 hover:bg-white/10 border border-white/10 py-4 rounded-sm text-[10px] font-black uppercase tracking-[0.2em] transition-all flex items-center justify-center gap-3 mt-auto">
|
|
||||||
<Download className="h-4 w-4" /> DOWNLOAD RECEIPT
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceLineItem({ title, rate, hours, total }: any) {
|
function getRelationName(relation: FinanceRow["clients"] | FinanceRow["projects"]) {
|
||||||
return (
|
if (!relation) return null;
|
||||||
<div className="grid grid-cols-12 gap-4 p-5 rounded-sm bg-[#150F1D] border border-white/5 items-center group hover:border-primary/30 transition-all">
|
return Array.isArray(relation) ? relation[0]?.name || null : relation.name;
|
||||||
<div className="col-span-7 flex flex-col">
|
|
||||||
<span className="text-xs font-black group-hover:text-primary transition-colors uppercase tracking-widest">{title}</span>
|
|
||||||
<span className="text-[9px] text-muted-foreground font-bold uppercase tracking-widest mt-1">Strategic Development Block</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-center text-xs font-black text-muted-foreground">{rate}</div>
|
|
||||||
<div className="col-span-1 text-center text-xs font-black text-muted-foreground">{hours}</div>
|
|
||||||
<div className="col-span-2 text-right text-sm font-black text-primary">{total}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailMeta({ label, value, isPrimary = false }: any) {
|
function normalizeType(type: string): FinanceTransactionItem["type"] {
|
||||||
return (
|
return type === "income" ? "income" : "expense";
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">{label}</span>
|
|
||||||
<span className={`text-sm font-black ${isPrimary ? 'text-primary' : 'text-foreground'}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinanceKpiCard({ label, value, change, trend, icon: Icon, color = "default" }: any) {
|
function normalizePaymentStatus(status: string): FinanceTransactionItem["payment_status"] {
|
||||||
const isUp = trend === "up";
|
if (status === "pending" || status === "paid" || status === "cancelled") {
|
||||||
const trendColor = isUp ? "text-emerald-500" : "text-rose-500";
|
return status;
|
||||||
|
}
|
||||||
return (
|
|
||||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm p-8 group relative overflow-hidden shadow-2xl transition-all hover:border-white/10">
|
return "planned";
|
||||||
<div className={`absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity ${color === 'primary' ? 'text-primary' : 'text-foreground'}`}>
|
|
||||||
<Icon className="h-20 w-20" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-black uppercase tracking-[0.3em] text-muted-foreground mb-4">{label}</div>
|
|
||||||
<div className="flex items-baseline gap-3 relative z-10">
|
|
||||||
<span className="text-3xl font-black text-foreground tracking-tighter leading-none">{value}</span>
|
|
||||||
<div className={`flex items-center text-[10px] font-black px-2 py-0.5 rounded-sm bg-white/5 ${trendColor}`}>
|
|
||||||
{isUp ? <ArrowUpRight className="h-3.5 w-3.5 mr-0.5" /> : <ArrowDownRight className="h-3.5 w-3.5 mr-0.5" />} {change}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
function cleanText(value: FormDataEntryValue | null) {
|
||||||
|
const text = typeof value === "string" ? value.trim() : "";
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScore(value: FormDataEntryValue | null) {
|
||||||
|
const score = Number(typeof value === "string" ? value : value?.toString());
|
||||||
|
return Number.isInteger(score) && score >= 1 && score <= 5 ? score : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUserId() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
throw new Error("Günlük kaydı için giriş yapmış kullanıcı bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supabase, userId: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayload(formData: FormData) {
|
||||||
|
return {
|
||||||
|
log_date: cleanText(formData.get("log_date")) || new Date().toISOString().slice(0, 10),
|
||||||
|
mood_score: readScore(formData.get("mood_score")),
|
||||||
|
energy_score: readScore(formData.get("energy_score")),
|
||||||
|
work_satisfaction_score: readScore(formData.get("work_satisfaction_score")),
|
||||||
|
note: cleanText(formData.get("note")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDailyLogRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const payload = readPayload(formData);
|
||||||
|
|
||||||
|
if (!payload.mood_score || !payload.energy_score) {
|
||||||
|
throw new Error("Mood ve enerji skorları zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("daily_logs")
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
{ onConflict: "user_id,log_date" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Günlük kaydı eklenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/journal");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDailyLogRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
const payload = readPayload(formData);
|
||||||
|
|
||||||
|
if (!id || !payload.mood_score || !payload.energy_score) {
|
||||||
|
throw new Error("Günlük kaydını güncellemek için kayıt kimliği, mood ve enerji skorları zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("daily_logs")
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Günlük kaydı güncellenemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/journal");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDailyLogRecord(formData: FormData) {
|
||||||
|
const { supabase, userId } = await getCurrentUserId();
|
||||||
|
const id = cleanText(formData.get("id"));
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Silinecek günlük kaydı bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("daily_logs")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Günlük kaydı silinemedi: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/journal");
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDailyLogRecord,
|
||||||
|
deleteDailyLogRecord,
|
||||||
|
updateDailyLogRecord,
|
||||||
|
} from "@/app/(dashboard)/journal/actions";
|
||||||
|
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "poyraz-ui/molecules";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Battery,
|
||||||
|
CalendarDays,
|
||||||
|
LineChart as LineChartIcon,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Smile,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type DailyLogItem = {
|
||||||
|
id: string;
|
||||||
|
log_date: string;
|
||||||
|
mood_score: number;
|
||||||
|
energy_score: number;
|
||||||
|
work_satisfaction_score: number | null;
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JournalClientProps = {
|
||||||
|
logs: DailyLogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreLabels: Record<number, string> = {
|
||||||
|
1: "Çok düşük",
|
||||||
|
2: "Düşük",
|
||||||
|
3: "Orta",
|
||||||
|
4: "İyi",
|
||||||
|
5: "Çok iyi",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JournalClient({ logs }: JournalClientProps) {
|
||||||
|
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
|
||||||
|
const filteredLogs = logs.filter((log) => log.log_date.startsWith(monthFilter));
|
||||||
|
const summary = useMemo(() => calculateSummary(filteredLogs), [filteredLogs]);
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
[...filteredLogs]
|
||||||
|
.sort((a, b) => a.log_date.localeCompare(b.log_date))
|
||||||
|
.map((log) => ({
|
||||||
|
date: formatShortDate(log.log_date),
|
||||||
|
mood: log.mood_score,
|
||||||
|
energy: log.energy_score,
|
||||||
|
satisfaction: log.work_satisfaction_score,
|
||||||
|
})),
|
||||||
|
[filteredLogs],
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Günlük durum
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||||
|
Mood ve enerji
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Günlük ruh hali, enerji ve çalışma memnuniyetini takip ederek kişisel kapasite trendini gör.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
type="month"
|
||||||
|
value={monthFilter}
|
||||||
|
onChange={(event) => setMonthFilter(event.target.value)}
|
||||||
|
className="sm:w-44"
|
||||||
|
/>
|
||||||
|
<DailyLogDialog mode="create" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="Ortalama mood"
|
||||||
|
value={summary.moodAverage ? summary.moodAverage.toFixed(1) : "-"}
|
||||||
|
icon={<Smile className="h-5 w-5" />}
|
||||||
|
tone="primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Ortalama enerji"
|
||||||
|
value={summary.energyAverage ? summary.energyAverage.toFixed(1) : "-"}
|
||||||
|
icon={<Battery className="h-5 w-5" />}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Memnuniyet"
|
||||||
|
value={summary.satisfactionAverage ? summary.satisfactionAverage.toFixed(1) : "-"}
|
||||||
|
icon={<LineChartIcon className="h-5 w-5" />}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Kayıtlı gün"
|
||||||
|
value={String(filteredLogs.length)}
|
||||||
|
icon={<CalendarDays className="h-5 w-5" />}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Aylık trend</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mood, enerji ve çalışma memnuniyetinin günlük değişimi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData} margin={{ left: -16, right: 16, top: 12, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||||
|
<YAxis domain={[1, 5]} tickCount={5} tickLine={false} axisLine={false} fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.08)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="mood" name="Mood" stroke="#dc2626" strokeWidth={3} dot={{ r: 3 }} />
|
||||||
|
<Line type="monotone" dataKey="energy" name="Enerji" stroke="#059669" strokeWidth={3} dot={{ r: 3 }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="satisfaction"
|
||||||
|
name="Memnuniyet"
|
||||||
|
stroke="#2563eb"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Kapasite sinyali</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Bu ayki günlük kayıtlardan kısa okuma.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
{summary.insights.map((insight) => (
|
||||||
|
<div key={insight} className="rounded-sm border border-border bg-muted/20 p-3">
|
||||||
|
{insight}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Günlük kayıtlar</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{filteredLogs.length} kayıt görüntüleniyor.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredLogs.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-sm border border-border">
|
||||||
|
<div className="hidden grid-cols-[0.7fr_0.7fr_0.7fr_1.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
|
||||||
|
<span>Tarih</span>
|
||||||
|
<span>Mood</span>
|
||||||
|
<span>Enerji</span>
|
||||||
|
<span>Not</span>
|
||||||
|
<span className="text-right">İşlem</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{filteredLogs.map((log) => (
|
||||||
|
<DailyLogRow key={log.id} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyLogRow({ log }: { log: DailyLogItem }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[0.7fr_0.7fr_0.7fr_1.8fr_0.8fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">{formatDate(log.log_date)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{formatWeekday(log.log_date)}</div>
|
||||||
|
</div>
|
||||||
|
<ScoreBadge score={log.mood_score} tone="primary" />
|
||||||
|
<ScoreBadge score={log.energy_score} tone="green" />
|
||||||
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
|
<p className="line-clamp-2">{log.note || "Not eklenmedi."}</p>
|
||||||
|
{log.work_satisfaction_score ? (
|
||||||
|
<p className="mt-1 text-xs">Çalışma memnuniyeti: {log.work_satisfaction_score}/5</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-start gap-2 lg:justify-end">
|
||||||
|
<DailyLogDialog mode="edit" log={log} />
|
||||||
|
<form action={deleteDailyLogRecord}>
|
||||||
|
<input type="hidden" name="id" value={log.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 DailyLogDialog({ mode, log }: { mode: "create" | "edit"; log?: DailyLogItem }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const action = mode === "create" ? createDailyLogRecord : updateDailyLogRecord;
|
||||||
|
|
||||||
|
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" ? "Günlük ekle" : "Düzenle"}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[min(640px,calc(100dvh-6rem))] overflow-hidden sm:max-w-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
|
||||||
|
<form action={handleSubmit} className="flex max-h-[min(600px,calc(100dvh-9rem))] flex-col">
|
||||||
|
{log ? <input type="hidden" name="id" value={log.id} /> : null}
|
||||||
|
<DialogHeader className="shrink-0 pb-5">
|
||||||
|
<DialogTitle>{mode === "create" ? "Yeni günlük kayıt" : "Günlük kaydı düzenle"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Günün mood, enerji ve çalışma memnuniyeti skorlarını kaydet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
|
<DailyLogFormFields log={log} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 border-t border-border pt-5">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="gap-2">
|
||||||
|
{isSubmitting ? "Kaydediliyor" : mode === "create" ? "Kaydı ekle" : "Değişiklikleri kaydet"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyLogFormFields({ log }: { log?: DailyLogItem }) {
|
||||||
|
const [moodScore, setMoodScore] = useState(log?.mood_score || 3);
|
||||||
|
const [energyScore, setEnergyScore] = useState(log?.energy_score || 3);
|
||||||
|
const [satisfactionScore, setSatisfactionScore] = useState(log?.work_satisfaction_score || 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Tarih</Label>
|
||||||
|
<Input
|
||||||
|
name="log_date"
|
||||||
|
type="date"
|
||||||
|
defaultValue={log?.log_date || new Date().toISOString().slice(0, 10)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScorePicker
|
||||||
|
name="mood_score"
|
||||||
|
label="Mood skoru"
|
||||||
|
value={moodScore}
|
||||||
|
onChange={setMoodScore}
|
||||||
|
tone="primary"
|
||||||
|
/>
|
||||||
|
<ScorePicker
|
||||||
|
name="energy_score"
|
||||||
|
label="Enerji skoru"
|
||||||
|
value={energyScore}
|
||||||
|
onChange={setEnergyScore}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
<ScorePicker
|
||||||
|
name="work_satisfaction_score"
|
||||||
|
label="Çalışma memnuniyeti"
|
||||||
|
value={satisfactionScore}
|
||||||
|
onChange={setSatisfactionScore}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Not</Label>
|
||||||
|
<Textarea
|
||||||
|
name="note"
|
||||||
|
defaultValue={log?.note || ""}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Bugün nasıl geçti, enerjini etkileyen şeyler nelerdi?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorePicker({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
tone: "primary" | "green" | "blue";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">{scoreLabels[value]}</span>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((score) => (
|
||||||
|
<button
|
||||||
|
key={score}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(score)}
|
||||||
|
className={`h-10 rounded-sm border text-sm font-semibold transition-colors ${
|
||||||
|
value === score
|
||||||
|
? getScoreActiveClass(tone)
|
||||||
|
: "border-border bg-background text-muted-foreground hover:border-primary/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green" }) {
|
||||||
|
const className =
|
||||||
|
tone === "green"
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-primary/20 bg-primary/10 text-primary";
|
||||||
|
|
||||||
|
return <Badge className={className}>{score}/5 · {scoreLabels[score]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
tone: "primary" | "green" | "blue" | "amber";
|
||||||
|
}) {
|
||||||
|
const toneClass = {
|
||||||
|
primary: "bg-primary/10 text-primary",
|
||||||
|
green: "bg-emerald-50 text-emerald-700",
|
||||||
|
blue: "bg-blue-50 text-blue-700",
|
||||||
|
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}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
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">
|
||||||
|
<Activity className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-foreground">Bu ay günlük kayıt yok</h3>
|
||||||
|
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
Mood ve enerji trendini görmek için ilk günlük kaydını ekle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSummary(logs: DailyLogItem[]) {
|
||||||
|
const moodAverage = average(logs.map((log) => log.mood_score));
|
||||||
|
const energyAverage = average(logs.map((log) => log.energy_score));
|
||||||
|
const satisfactionAverage = average(
|
||||||
|
logs
|
||||||
|
.map((log) => log.work_satisfaction_score)
|
||||||
|
.filter((score): score is number => typeof score === "number"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const insights = [];
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
insights.push("Bu ay için henüz okunabilir bir trend yok.");
|
||||||
|
} else {
|
||||||
|
insights.push(`Bu ay ${logs.length} günlük kayıt var.`);
|
||||||
|
insights.push(
|
||||||
|
energyAverage && energyAverage < 3
|
||||||
|
? "Enerji ortalaması düşük. Dashboard raporlarında geciken işler ile birlikte okunmalı."
|
||||||
|
: "Enerji ortalaması dengeli görünüyor.",
|
||||||
|
);
|
||||||
|
insights.push(
|
||||||
|
moodAverage && moodAverage >= 4
|
||||||
|
? "Mood seviyesi güçlü. Yüksek odak isteyen işler için iyi bir dönem olabilir."
|
||||||
|
: "Mood trendi izlenmeli. Not alanı hangi günlerin zor geçtiğini anlamak için önemli.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { moodAverage, energyAverage, satisfactionAverage, insights };
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: number[]) {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreActiveClass(tone: "primary" | "green" | "blue") {
|
||||||
|
if (tone === "green") return "border-emerald-600 bg-emerald-600 text-white";
|
||||||
|
if (tone === "blue") return "border-blue-600 bg-blue-600 text-white";
|
||||||
|
return "border-primary bg-primary text-primary-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}).format(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekday(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
weekday: "long",
|
||||||
|
}).format(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
@@ -1,294 +1,41 @@
|
|||||||
"use client";
|
import { JournalClient, type DailyLogItem } from "@/app/(dashboard)/journal/journal-client";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
import { useState } from "react";
|
type DailyLogRow = {
|
||||||
import {
|
id: string;
|
||||||
Plus, Search, Calendar, Clock, Brain, MessageSquare,
|
log_date: string;
|
||||||
Smile, Frown, Meh, Star, MoreHorizontal, X,
|
mood_score: number;
|
||||||
Zap, Save, Trash2, Edit3, Image as ImageIcon, Link as LinkIcon,
|
energy_score: number;
|
||||||
ChevronLeft, ChevronRight, Activity, Filter, AlignLeft, Hash, ArrowRight
|
work_satisfaction_score: number | null;
|
||||||
} from "lucide-react";
|
note: string | null;
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
};
|
||||||
|
|
||||||
// Mock Data
|
export default async function JournalPage() {
|
||||||
const journalEntries = [
|
const supabase = await createClient();
|
||||||
{
|
const {
|
||||||
id: 1,
|
data: { user },
|
||||||
date: "May 15, 2026",
|
} = await supabase.auth.getUser();
|
||||||
title: "Deep Work Breakthrough",
|
|
||||||
excerpt: "Today I finally cracked the multi-tenant architecture logic for the Cognis core. Energy was high after the morning focus block.",
|
|
||||||
sentiment: "Great",
|
|
||||||
moodScore: 92,
|
|
||||||
tags: ["Productivity", "Coding"],
|
|
||||||
content: "The morning started with a 4-hour deep work block. I avoided all notifications and focused purely on the database schema. The multi-tenant logic is now solid. I feel a huge weight off my shoulders. Physical energy was 9/10 thanks to the 7am gym session."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
date: "May 14, 2026",
|
|
||||||
title: "Project Risk Discussion",
|
|
||||||
excerpt: "Met with Alex regarding the Infrastructure delays. Felt a bit anxious about the timeline but the AI risk report helped us focus.",
|
|
||||||
sentiment: "Neutral",
|
|
||||||
moodScore: 65,
|
|
||||||
tags: ["Meeting", "Stress"],
|
|
||||||
content: "Alex and I went through the infrastructure roadmap. We are indeed behind on the database migration. The stress is real, but we have a plan now. AI suggests prioritizing the migration scripts. Note for tomorrow: focus on script optimization."
|
|
||||||
},
|
|
||||||
{ id: 3, date: "May 12, 2026", title: "Creative Flow", excerpt: "Spent the afternoon in Figma. The new 'Cyber-Lavender' palette is looking stunning in the dark mode previews.", sentiment: "Great", moodScore: 88, tags: ["Design", "Creative"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function JournalPage() {
|
if (!user) {
|
||||||
const [selectedEntry, setSelectedEntry] = useState<any>(null);
|
return null;
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
}
|
||||||
|
|
||||||
return (
|
const { data: logRows } = await supabase
|
||||||
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans space-y-6 pb-12 relative">
|
.from("daily_logs")
|
||||||
|
.select("id, log_date, mood_score, energy_score, work_satisfaction_score, note")
|
||||||
{/* Top Header */}
|
.eq("user_id", user.id)
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
|
.order("log_date", { ascending: false })
|
||||||
<div className="flex items-center gap-4">
|
.limit(180);
|
||||||
<h1 className="text-lg font-medium text-muted-foreground">
|
|
||||||
<span className="text-foreground">Mindset</span> / Strategic Journal
|
|
||||||
</h1>
|
|
||||||
<div className="h-4 w-px bg-white/10" />
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-primary">
|
|
||||||
<Edit3 className="h-3 w-3" /> 128 ENTRIES RECORDED
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search thoughts..."
|
|
||||||
className="bg-transparent border-none outline-none text-xs w-48 placeholder:text-muted-foreground/50 text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreating(true)}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-5 py-2 rounded-sm text-xs font-black tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-primary/30 active:scale-95"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
NEW ENTRY
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex gap-8 min-h-0">
|
const logs: DailyLogItem[] = ((logRows || []) as DailyLogRow[]).map((log) => ({
|
||||||
|
id: log.id,
|
||||||
{/* Left Sidebar: Entries List */}
|
log_date: log.log_date,
|
||||||
<div className="w-full max-w-sm flex flex-col gap-4 overflow-y-auto tiny-scrollbar pr-2">
|
mood_score: Number(log.mood_score),
|
||||||
<div className="flex items-center justify-between px-2 mb-2">
|
energy_score: Number(log.energy_score),
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-muted-foreground">Recent Reflections</h3>
|
work_satisfaction_score:
|
||||||
<button className="text-[10px] font-bold text-muted-foreground hover:text-foreground flex items-center gap-1">
|
typeof log.work_satisfaction_score === "number" ? Number(log.work_satisfaction_score) : null,
|
||||||
<Filter className="h-3 w-3" /> FILTER
|
note: log.note,
|
||||||
</button>
|
}));
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{journalEntries.map(entry => (
|
|
||||||
<motion.div
|
|
||||||
key={entry.id}
|
|
||||||
whileHover={{ x: 4 }}
|
|
||||||
onClick={() => setSelectedEntry(entry)}
|
|
||||||
className={`p-5 rounded-sm border cursor-pointer transition-all ${selectedEntry?.id === entry.id ? 'bg-[#1F172B] border-primary/40 shadow-xl' : 'bg-[#0A0710] border-white/5 hover:border-white/10'}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">{entry.date}</span>
|
|
||||||
<SentimentIcon sentiment={entry.sentiment} />
|
|
||||||
</div>
|
|
||||||
<h3 className={`text-sm font-black mb-2 transition-colors ${selectedEntry?.id === entry.id ? 'text-primary' : 'text-foreground'}`}>{entry.title}</h3>
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2 italic">"{entry.excerpt}"</p>
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
{entry.tags.map(tag => (
|
|
||||||
<span key={tag} className="text-[8px] font-black uppercase tracking-widest px-2 py-0.5 rounded-sm bg-white/5 text-muted-foreground">#{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content: Entry Viewer/Editor */}
|
return <JournalClient logs={logs} />;
|
||||||
<div className="flex-1 bg-[#0A0710] border border-white/5 rounded-sm flex flex-col relative overflow-hidden shadow-2xl">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{selectedEntry ? (
|
|
||||||
<motion.div
|
|
||||||
key={selectedEntry.id}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
className="flex-1 flex flex-col"
|
|
||||||
>
|
|
||||||
<div className="p-10 border-b border-white/5 flex items-center justify-between bg-primary/5">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">{selectedEntry.date}</span>
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-white/20" />
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-black text-emerald-400">
|
|
||||||
<Activity className="h-3 w-3" /> MOOD SCORE: {selectedEntry.moodScore}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-black tracking-tighter text-foreground">{selectedEntry.title}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="p-2.5 hover:bg-white/5 rounded-sm text-muted-foreground transition-colors"><Edit3 className="h-5 w-5" /></button>
|
|
||||||
<button className="p-2.5 hover:bg-white/5 rounded-sm text-rose-500 transition-colors"><Trash2 className="h-5 w-5" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 p-10 overflow-y-auto tiny-scrollbar space-y-12">
|
|
||||||
|
|
||||||
{/* AI Psychological Insight */}
|
|
||||||
<div className="rounded-sm border border-primary/20 bg-primary/5 p-8 space-y-4 relative overflow-hidden">
|
|
||||||
<div className="absolute -right-4 -top-4 opacity-5">
|
|
||||||
<Brain className="h-24 w-24 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
|
|
||||||
<Brain className="h-4 w-4" /> Cognitive Analysis
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm font-medium text-foreground/90 leading-relaxed italic">
|
|
||||||
"I've noticed that your mood scores peak when you mention 'Deep Work' blocks in the morning. However, meeting-heavy days seem to correlate with anxious sentiment. Consider restructuring 'Infrastructure' discussions for early PM."
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body Content */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-lg text-foreground/90 leading-[1.8] font-medium tracking-tight">
|
|
||||||
{selectedEntry.content || "No detailed content available for this entry."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Strategic Connections */}
|
|
||||||
<div className="pt-10 border-t border-white/5 space-y-6">
|
|
||||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Connected Strategics</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 rounded-sm bg-[#150F1D] border border-white/5 flex items-center justify-between group cursor-pointer hover:border-primary/30 transition-all">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-primary/10 rounded-sm"><Zap className="h-4 w-4 text-primary" /></div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-bold text-foreground">Goal: Scaling Backend</div>
|
|
||||||
<div className="text-[9px] text-muted-foreground uppercase mt-1">Directly Referenced</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground group-hover:text-primary transition-all" />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 rounded-sm bg-[#150F1D] border border-white/5 flex items-center justify-between group cursor-pointer hover:border-primary/30 transition-all">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-emerald-500/10 rounded-sm"><Clock className="h-4 w-4 text-emerald-500" /></div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-bold text-foreground">Habit: 7AM Gym</div>
|
|
||||||
<div className="text-[9px] text-muted-foreground uppercase mt-1">Impact Observed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground group-hover:text-primary transition-all" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 border-t border-white/5 bg-[#0F0B15]/40 flex justify-between items-center">
|
|
||||||
<div className="flex -space-x-2">
|
|
||||||
{[1, 2, 3].map(i => <div key={i} className="h-8 w-8 rounded-full border-2 border-[#0A0710] bg-[#1F172B] flex items-center justify-center text-[10px] font-bold">A{i}</div>)}
|
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-[#0A0710] bg-primary/20 flex items-center justify-center text-[10px] font-bold text-primary">+2</div>
|
|
||||||
</div>
|
|
||||||
<button className="text-[10px] font-black text-primary uppercase tracking-widest flex items-center gap-2 hover:bg-primary/10 px-4 py-2 rounded-sm transition-all">
|
|
||||||
<MessageSquare className="h-4 w-4" /> 12 COMMENTS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center space-y-6">
|
|
||||||
<div className="p-6 bg-white/5 rounded-full">
|
|
||||||
<AlignLeft className="h-12 w-12 text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-black uppercase tracking-widest text-muted-foreground">No Entry Selected</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xs mx-auto italic">Select a reflection from the sidebar or create a new strategic entry to begin.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreating(true)}
|
|
||||||
className="bg-primary/10 text-primary border border-primary/20 px-6 py-2.5 rounded-sm text-xs font-black tracking-widest uppercase hover:bg-primary/20 transition-all"
|
|
||||||
>
|
|
||||||
Create Your First Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Entry Modal */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isCreating && (
|
|
||||||
<>
|
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setIsCreating(false)} className="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100]" />
|
|
||||||
<motion.div initial={{ scale: 0.95, opacity: 0, y: 20 }} animate={{ scale: 1, opacity: 1, y: 0 }} exit={{ scale: 0.95, opacity: 0, y: 20 }} className="fixed inset-0 m-auto w-full max-w-4xl h-[85vh] bg-[#0A0710] border border-white/10 z-[101] shadow-2xl flex flex-col rounded-sm overflow-hidden">
|
|
||||||
<div className="p-8 border-b border-white/5 flex items-center justify-between bg-primary/10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-2.5 bg-primary rounded-sm shadow-xl shadow-primary/20"><Edit3 className="h-5 w-5 text-primary-foreground" /></div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-black uppercase tracking-tight">New Reflection</h2>
|
|
||||||
<p className="text-[10px] font-black text-primary tracking-[0.3em] uppercase">Documenting Strategic Growth</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setIsCreating(false)} className="p-2 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><X className="h-7 w-7" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col p-10 space-y-8 overflow-y-auto tiny-scrollbar">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Title of Reflection</label>
|
|
||||||
<input type="text" placeholder="e.g., Q3 Breakthrough or Team Alignment thoughts..." className="w-full bg-transparent border-none text-3xl font-black placeholder:text-muted-foreground/20 outline-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6 pb-6 border-b border-white/5">
|
|
||||||
<div className="flex items-center gap-3 bg-white/5 px-4 py-2 rounded-sm border border-white/5">
|
|
||||||
<Calendar className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-xs font-bold">May 15, 2026</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Sentiment:</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="p-2 rounded-sm bg-white/5 hover:bg-emerald-500/20 hover:text-emerald-500 transition-all"><Smile className="h-5 w-5" /></button>
|
|
||||||
<button className="p-2 rounded-sm bg-white/5 hover:bg-primary/20 hover:text-primary transition-all"><Meh className="h-5 w-5" /></button>
|
|
||||||
<button className="p-2 rounded-sm bg-white/5 hover:bg-rose-500/20 hover:text-rose-500 transition-all"><Frown className="h-5 w-5" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-[300px]">
|
|
||||||
<textarea
|
|
||||||
placeholder="Write your strategic reflections here... Use # to link goals or tasks."
|
|
||||||
className="w-full h-full bg-transparent border-none outline-none text-lg leading-relaxed text-foreground/80 resize-none placeholder:text-muted-foreground/10 font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-6 border-t border-white/5">
|
|
||||||
<button className="p-2 text-muted-foreground hover:text-primary transition-colors"><ImageIcon className="h-5 w-5" /></button>
|
|
||||||
<button className="p-2 text-muted-foreground hover:text-primary transition-colors"><LinkIcon className="h-5 w-5" /></button>
|
|
||||||
<button className="p-2 text-muted-foreground hover:text-primary transition-colors"><Hash className="h-5 w-5" /></button>
|
|
||||||
<div className="ml-auto flex items-center gap-2 text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest">
|
|
||||||
<Clock className="h-3 w-3" /> Auto-saving to Cloud...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-10 border-t border-white/5 flex gap-6 bg-[#0F0B15]/60">
|
|
||||||
<button className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground py-5 rounded-sm text-[11px] font-black uppercase tracking-[0.3em] shadow-2xl shadow-primary/40 transition-all active:scale-[0.98] flex items-center justify-center gap-3">
|
|
||||||
<Save className="h-5 w-5" /> PUBLISH REFLECTION
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setIsCreating(false)} className="px-10 border border-white/10 rounded-sm text-[11px] font-black uppercase tracking-[0.2em] text-muted-foreground hover:bg-white/5 transition-all">
|
|
||||||
DISCARD
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SentimentIcon({ sentiment }: { sentiment: string }) {
|
|
||||||
if (sentiment === "Great") return <Smile className="h-4 w-4 text-emerald-500" />;
|
|
||||||
if (sentiment === "Neutral") return <Meh className="h-4 w-4 text-primary" />;
|
|
||||||
return <Frown className="h-4 w-4 text-rose-500" />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user