From 3504a02229746fd4539454fef9181d430d7973e3 Mon Sep 17 00:00:00 2001
From: Poyraz <78071274+poyrazavsever@users.noreply.github.com>
Date: Wed, 17 Jun 2026 09:20:21 +0300
Subject: [PATCH] feat: implement analytics dashboard page with custom Recharts
visualizations and Supabase RPC integration
---
.../analytics/analytics-client.tsx | 56 ++----
app/(dashboard)/analytics/page.tsx | 55 ++++--
app/(dashboard)/dashboard-client.tsx | 85 +++------
app/(dashboard)/page.tsx | 77 ++++----
docs/04-supabase-kurulumu.md | 4 +-
.../migrations/0012_add_analytics_rpcs.sql | 161 +++++++++++++++++
.../20260617055800_add_analytics_rpcs.sql | 0
supabase/setup.sql | 170 +++++++++++++++++-
8 files changed, 463 insertions(+), 145 deletions(-)
create mode 100644 supabase/migrations/0012_add_analytics_rpcs.sql
create mode 100644 supabase/migrations/20260617055800_add_analytics_rpcs.sql
diff --git a/app/(dashboard)/analytics/analytics-client.tsx b/app/(dashboard)/analytics/analytics-client.tsx
index a814b69..8157d9a 100644
--- a/app/(dashboard)/analytics/analytics-client.tsx
+++ b/app/(dashboard)/analytics/analytics-client.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
import {
@@ -10,9 +10,12 @@ import {
import { BarChart3, Filter } from "lucide-react";
export type AnalyticsData = {
- tasks: { id: string; status: string; created_at: string; due_at?: string }[];
- projects: { id: string; name: string }[];
- finances: { id: string; type: string; amount: number; transaction_date: string; project_id?: string }[];
+ metrics: {
+ projectIncomeData: { name: string; value: number }[];
+ completedTasks: number;
+ activeTasks: number;
+ };
+ range: string;
};
type AnalyticsClientProps = {
@@ -22,44 +25,17 @@ type AnalyticsClientProps = {
const COLORS = ["hsl(var(--primary))", "hsl(var(--destructive))", "#eab308", "#3b82f6", "#8b5cf6"];
export function AnalyticsClient({ data }: AnalyticsClientProps) {
- const [dateRange, setDateRange] = useState("this_month");
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
- const now = new Date();
-
- const filterByDate = (dateStr: string | null) => {
- if (!dateStr) return false;
- const date = new Date(dateStr);
- if (dateRange === "this_week") {
- const firstDay = new Date(now.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1)));
- return date >= firstDay;
- }
- if (dateRange === "this_month") {
- return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
- }
- if (dateRange === "this_year") {
- return date.getFullYear() === now.getFullYear();
- }
- return true;
+ const handleRangeChange = (newRange: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("range", newRange);
+ router.push(`${pathname}?${params.toString()}`);
};
- const filteredFinances = data.finances.filter(f => filterByDate(f.transaction_date));
- const filteredTasks = data.tasks.filter(t => filterByDate(t.created_at || t.due_at || null));
-
- // Project-based income
- const projectIncomeMap = new Map();
- filteredFinances.filter(f => f.type === "income" && f.project_id).forEach(f => {
- const project = data.projects.find(p => p.id === f.project_id);
- const name = project ? project.name : "Bilinmeyen";
- if (!projectIncomeMap.has(name)) {
- projectIncomeMap.set(name, { name, value: 0 });
- }
- projectIncomeMap.get(name).value += Number(f.amount);
- });
- const projectIncomeData = Array.from(projectIncomeMap.values());
-
- // Task completion stats
- const completedTasks = filteredTasks.filter(t => t.status === "done").length;
- const activeTasks = filteredTasks.filter(t => t.status !== "done" && t.status !== "cancelled").length;
+ const { projectIncomeData, completedTasks, activeTasks } = data.metrics;
const taskStatusData = [
{ name: "Tamamlanan", value: completedTasks },
@@ -85,7 +61,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
-
-
+
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index 270ad51..34ac59d 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -6,7 +6,11 @@ export const metadata = {
title: "Dashboard - Neta",
};
-export default async function DashboardPage() {
+export default async function DashboardPage({
+ searchParams,
+}: {
+ searchParams: { [key: string]: string | string[] | undefined };
+}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -14,41 +18,48 @@ export default async function DashboardPage() {
redirect("/login");
}
- // Get current date and date 30 days ago
- const today = new Date();
- const thirtyDaysAgo = new Date();
- thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+ const range = typeof searchParams.range === "string" ? searchParams.range : "this_month";
- const todayStr = today.toISOString();
- const thirtyDaysAgoStr = thirtyDaysAgo.toISOString();
+ const now = new Date();
+ let startDate = new Date();
+ let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); // default to end of month
+
+ if (range === "today") {
+ startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
+ endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
+ } else if (range === "this_week") {
+ // Reset `now` because setDate mutates
+ const tempNow = new Date();
+ const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1)));
+ firstDay.setHours(0, 0, 0, 0);
+ startDate = firstDay;
+ endDate = new Date(firstDay.getTime());
+ endDate.setDate(endDate.getDate() + 6);
+ endDate.setHours(23, 59, 59, 999);
+ } else if (range === "this_month") {
+ startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0);
+ endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
+ } else if (range === "this_year") {
+ startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0);
+ endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59);
+ }
- // Fetch all dashboard data in parallel with only needed columns
+ // Fetch metrics using RPC
+ const { data: metricsData } = await supabase.rpc('get_dashboard_metrics', {
+ p_start_date: startDate.toISOString(),
+ p_end_date: endDate.toISOString()
+ });
+
+ // Fetch limited recent data
const [
- { data: tasks },
{ data: projects },
- { data: finances },
- { data: logs },
{ data: clients },
] = await Promise.all([
- supabase
- .from("tasks")
- .select("id, status, created_at, updated_at, due_at")
- .or(`due_at.gte.${thirtyDaysAgoStr},created_at.gte.${thirtyDaysAgoStr}`)
- .order("due_at", { ascending: true }),
supabase
.from("projects")
.select("id, status, name, created_at")
- .order("created_at", { ascending: false }),
- supabase
- .from("finance_transactions")
- .select("id, type, amount, transaction_date")
- .gte("transaction_date", thirtyDaysAgoStr)
- .order("transaction_date", { ascending: true }),
- supabase
- .from("daily_logs")
- .select("id, log_date, mood_score, energy_score")
- .gte("log_date", thirtyDaysAgoStr)
- .order("log_date", { ascending: true }),
+ .order("created_at", { ascending: false })
+ .limit(5),
supabase
.from("clients")
.select("id, name, company_name, created_at")
@@ -57,11 +68,17 @@ export default async function DashboardPage() {
]);
const dashboardData = {
- tasks: tasks || [],
+ metrics: metricsData || {
+ netProfit: 0,
+ activeProjectsCount: 0,
+ completedTasksCount: 0,
+ avgMood: "0.0",
+ financeTrend: [],
+ moodTrend: []
+ },
projects: projects || [],
- finances: finances || [],
- logs: logs || [],
clients: clients || [],
+ range
};
return ;
diff --git a/docs/04-supabase-kurulumu.md b/docs/04-supabase-kurulumu.md
index a30aac1..913b5b0 100644
--- a/docs/04-supabase-kurulumu.md
+++ b/docs/04-supabase-kurulumu.md
@@ -24,7 +24,7 @@ Repo içinde `supabase/` ve `docs/database/` klasörleri tutulmaya devam eder. B
## Tek Seferlik Kurulum SQL'i
-Yeni bir Supabase projesini Neta için hazırlamanın en kolay yolu `supabase/setup.sql` dosyasını çalıştırmak. Bu dosya `supabase/schema.sql` ve `supabase/migrations/0002..0011` arasındaki migration dosyalarının tek dosyada birleştirilmiş hâlidir.
+Yeni bir Supabase projesini Neta için hazırlamanın en kolay yolu `supabase/setup.sql` dosyasını çalıştırmak. Bu dosya `supabase/schema.sql` ve `supabase/migrations/0002..0012` arasındaki migration dosyalarının tek dosyada birleştirilmiş hâlidir.
Demo seed verisi bu dosyaya dahil değildir. Production kurulumda örnek veri istemediğim için seed ayrı tutulur.
@@ -98,6 +98,8 @@ Uygulama şu function/RPC yapılarına ihtiyaç duyar:
- `is_first_admin_setup_available`
- `request_internal_auth_creation`
- `match_documents`
+- `get_dashboard_metrics` — Dashboard KPI ve grafik verilerini sunucu tarafında hesaplar
+- `get_analytics_metrics` — Analiz sayfası proje gelir dağılımı ve görev istatistiklerini hesaplar
- Yeni auth user için profile oluşturan trigger/function
- Proje ilerlemesini görev durumuna göre güncelleyen trigger/function
diff --git a/supabase/migrations/0012_add_analytics_rpcs.sql b/supabase/migrations/0012_add_analytics_rpcs.sql
new file mode 100644
index 0000000..eabbc4a
--- /dev/null
+++ b/supabase/migrations/0012_add_analytics_rpcs.sql
@@ -0,0 +1,161 @@
+-- 0012_add_analytics_rpcs.sql
+-- RPCs for Dashboard and Analytics aggregations to optimize frontend payload size
+
+CREATE OR REPLACE FUNCTION public.get_dashboard_metrics(
+ p_start_date TIMESTAMP WITH TIME ZONE,
+ p_end_date TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID := auth.uid();
+ v_income NUMERIC;
+ v_expense NUMERIC;
+ v_net_profit NUMERIC;
+ v_active_projects INT;
+ v_completed_tasks INT;
+ v_avg_mood NUMERIC;
+ v_finance_trend JSONB;
+ v_mood_trend JSONB;
+BEGIN
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ -- 1. Net Profit
+ SELECT COALESCE(SUM(amount), 0) INTO v_income
+ FROM finance_transactions
+ WHERE user_id = v_user_id AND type = 'income'
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
+
+ SELECT COALESCE(SUM(amount), 0) INTO v_expense
+ FROM finance_transactions
+ WHERE user_id = v_user_id AND type = 'expense'
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
+
+ v_net_profit := v_income - v_expense;
+
+ -- 2. Active Projects
+ SELECT COUNT(*) INTO v_active_projects
+ FROM projects
+ WHERE user_id = v_user_id AND status = 'active';
+
+ -- 3. Completed Tasks
+ SELECT COUNT(*) INTO v_completed_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status = 'done'
+ AND COALESCE(updated_at, created_at) >= p_start_date AND COALESCE(updated_at, created_at) <= p_end_date;
+
+ -- 4. Average Mood
+ SELECT COALESCE(ROUND(AVG(mood_score)::numeric, 1), 0) INTO v_avg_mood
+ FROM daily_logs
+ WHERE user_id = v_user_id
+ AND log_date >= p_start_date::date AND log_date <= p_end_date::date;
+
+ -- 5. Finance Trend (group by date)
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'date', t.t_date,
+ 'income', t.inc,
+ 'expense', t.exp
+ )
+ ), '[]'::jsonb) INTO v_finance_trend
+ FROM (
+ SELECT
+ transaction_date AS t_date,
+ SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS inc,
+ SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS exp
+ FROM finance_transactions
+ WHERE user_id = v_user_id
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date
+ GROUP BY transaction_date
+ ORDER BY transaction_date ASC
+ ) t;
+
+ -- 6. Mood Trend
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'date', log_date,
+ 'mood', mood_score,
+ 'energy', energy_score
+ )
+ ), '[]'::jsonb) INTO v_mood_trend
+ FROM (
+ SELECT log_date, mood_score, energy_score
+ FROM daily_logs
+ WHERE user_id = v_user_id
+ AND log_date >= p_start_date::date AND log_date <= p_end_date::date
+ ORDER BY log_date ASC
+ ) m;
+
+ RETURN jsonb_build_object(
+ 'netProfit', v_net_profit,
+ 'activeProjectsCount', v_active_projects,
+ 'completedTasksCount', v_completed_tasks,
+ 'avgMood', v_avg_mood::text,
+ 'financeTrend', v_finance_trend,
+ 'moodTrend', v_mood_trend
+ );
+END;
+$$;
+
+
+CREATE OR REPLACE FUNCTION public.get_analytics_metrics(
+ p_start_date TIMESTAMP WITH TIME ZONE,
+ p_end_date TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID := auth.uid();
+ v_project_income JSONB;
+ v_completed_tasks INT;
+ v_active_tasks INT;
+BEGIN
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ -- 1. Project Income Data
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'name', t.p_name,
+ 'value', t.total_amount
+ )
+ ), '[]'::jsonb) INTO v_project_income
+ FROM (
+ SELECT
+ COALESCE(p.name, 'Bilinmeyen') AS p_name,
+ SUM(f.amount) AS total_amount
+ FROM finance_transactions f
+ LEFT JOIN projects p ON f.project_id = p.id
+ WHERE f.user_id = v_user_id AND f.type = 'income'
+ AND f.transaction_date >= p_start_date::date AND f.transaction_date <= p_end_date::date
+ GROUP BY p.name
+ ORDER BY total_amount DESC
+ ) t;
+
+ -- 2. Task completion stats
+ SELECT COUNT(*) INTO v_completed_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status = 'done'
+ AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
+
+ SELECT COUNT(*) INTO v_active_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status != 'done' AND status != 'cancelled'
+ AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
+
+ RETURN jsonb_build_object(
+ 'projectIncomeData', v_project_income,
+ 'completedTasks', v_completed_tasks,
+ 'activeTasks', v_active_tasks
+ );
+END;
+$$;
diff --git a/supabase/migrations/20260617055800_add_analytics_rpcs.sql b/supabase/migrations/20260617055800_add_analytics_rpcs.sql
new file mode 100644
index 0000000..e69de29
diff --git a/supabase/setup.sql b/supabase/setup.sql
index 39c36d1..41a66c3 100644
--- a/supabase/setup.sql
+++ b/supabase/setup.sql
@@ -1,6 +1,6 @@
-- Neta one-shot Supabase setup SQL
-- Run this once in a fresh Supabase project after Auth and Storage schemas are available.
--- This file is generated from supabase/schema.sql and supabase/migrations/0002..0011.
+-- This file is generated from supabase/schema.sql and supabase/migrations/0002..0012.
-- Demo seed data is intentionally not included.
@@ -1560,4 +1560,172 @@ create policy "Users can delete their own project assets." on storage.objects
)
);
+-- -----------------------------------------------------------------------------
+-- Source: supabase/migrations/0012_add_analytics_rpcs.sql
+-- -----------------------------------------------------------------------------
+
+-- 0012: Dashboard & Analytics aggregate RPC functions
+-- These functions run aggregation queries inside PostgreSQL so the frontend
+-- receives pre-computed JSON instead of raw rows. This significantly reduces
+-- network payload and eliminates client-side reduce/filter overhead.
+
+CREATE OR REPLACE FUNCTION public.get_dashboard_metrics(
+ p_start_date TIMESTAMP WITH TIME ZONE,
+ p_end_date TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID := auth.uid();
+ v_income NUMERIC;
+ v_expense NUMERIC;
+ v_net_profit NUMERIC;
+ v_active_projects INT;
+ v_completed_tasks INT;
+ v_avg_mood NUMERIC;
+ v_finance_trend JSONB;
+ v_mood_trend JSONB;
+BEGIN
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ -- 1. Net Profit
+ SELECT COALESCE(SUM(amount), 0) INTO v_income
+ FROM finance_transactions
+ WHERE user_id = v_user_id AND type = 'income'
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
+
+ SELECT COALESCE(SUM(amount), 0) INTO v_expense
+ FROM finance_transactions
+ WHERE user_id = v_user_id AND type = 'expense'
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date;
+
+ v_net_profit := v_income - v_expense;
+
+ -- 2. Active Projects
+ SELECT COUNT(*) INTO v_active_projects
+ FROM projects
+ WHERE user_id = v_user_id AND status = 'active';
+
+ -- 3. Completed Tasks
+ SELECT COUNT(*) INTO v_completed_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status = 'done'
+ AND COALESCE(updated_at, created_at) >= p_start_date AND COALESCE(updated_at, created_at) <= p_end_date;
+
+ -- 4. Average Mood
+ SELECT COALESCE(ROUND(AVG(mood_score)::numeric, 1), 0) INTO v_avg_mood
+ FROM daily_logs
+ WHERE user_id = v_user_id
+ AND log_date >= p_start_date::date AND log_date <= p_end_date::date;
+
+ -- 5. Finance Trend (group by date)
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'date', t.t_date,
+ 'income', t.inc,
+ 'expense', t.exp
+ )
+ ), '[]'::jsonb) INTO v_finance_trend
+ FROM (
+ SELECT
+ transaction_date AS t_date,
+ SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS inc,
+ SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS exp
+ FROM finance_transactions
+ WHERE user_id = v_user_id
+ AND transaction_date >= p_start_date::date AND transaction_date <= p_end_date::date
+ GROUP BY transaction_date
+ ORDER BY transaction_date ASC
+ ) t;
+
+ -- 6. Mood Trend
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'date', log_date,
+ 'mood', mood_score,
+ 'energy', energy_score
+ )
+ ), '[]'::jsonb) INTO v_mood_trend
+ FROM (
+ SELECT log_date, mood_score, energy_score
+ FROM daily_logs
+ WHERE user_id = v_user_id
+ AND log_date >= p_start_date::date AND log_date <= p_end_date::date
+ ORDER BY log_date ASC
+ ) m;
+
+ RETURN jsonb_build_object(
+ 'netProfit', v_net_profit,
+ 'activeProjectsCount', v_active_projects,
+ 'completedTasksCount', v_completed_tasks,
+ 'avgMood', v_avg_mood::text,
+ 'financeTrend', v_finance_trend,
+ 'moodTrend', v_mood_trend
+ );
+END;
+$$;
+
+
+CREATE OR REPLACE FUNCTION public.get_analytics_metrics(
+ p_start_date TIMESTAMP WITH TIME ZONE,
+ p_end_date TIMESTAMP WITH TIME ZONE
+)
+RETURNS JSONB
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_user_id UUID := auth.uid();
+ v_project_income JSONB;
+ v_completed_tasks INT;
+ v_active_tasks INT;
+BEGIN
+ IF v_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ -- 1. Project Income Data
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'name', t.p_name,
+ 'value', t.total_amount
+ )
+ ), '[]'::jsonb) INTO v_project_income
+ FROM (
+ SELECT
+ COALESCE(p.name, 'Bilinmeyen') AS p_name,
+ SUM(f.amount) AS total_amount
+ FROM finance_transactions f
+ LEFT JOIN projects p ON f.project_id = p.id
+ WHERE f.user_id = v_user_id AND f.type = 'income'
+ AND f.transaction_date >= p_start_date::date AND f.transaction_date <= p_end_date::date
+ GROUP BY p.name
+ ORDER BY total_amount DESC
+ ) t;
+
+ -- 2. Task completion stats
+ SELECT COUNT(*) INTO v_completed_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status = 'done'
+ AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
+
+ SELECT COUNT(*) INTO v_active_tasks
+ FROM tasks
+ WHERE user_id = v_user_id AND status != 'done' AND status != 'cancelled'
+ AND COALESCE(due_at, created_at) >= p_start_date AND COALESCE(due_at, created_at) <= p_end_date;
+
+ RETURN jsonb_build_object(
+ 'projectIncomeData', v_project_income,
+ 'completedTasks', v_completed_tasks,
+ 'activeTasks', v_active_tasks
+ );
+END;
+$$;
+
notify pgrst, 'reload schema';