feat: implement analytics dashboard page with custom Recharts visualizations and Supabase RPC integration

This commit is contained in:
Poyraz
2026-06-17 09:20:21 +03:00
parent 3634b3a227
commit 3504a02229
8 changed files with 463 additions and 145 deletions
+16 -40
View File
@@ -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) {
</div>
<div className="flex items-center gap-3">
<Select value={dateRange} onValueChange={setDateRange}>
<Select value={data.range} onValueChange={handleRangeChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tarih aralığı" />
</SelectTrigger>
+38 -17
View File
@@ -6,7 +6,11 @@ export const metadata = {
title: "Analizler - Neta",
};
export default async function AnalyticsPage() {
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -14,24 +18,41 @@ export default async function AnalyticsPage() {
redirect("/login");
}
// Fetch all analytics data in parallel with only needed columns
const [{ data: tasks }, { data: projects }, { data: finances }] =
await Promise.all([
supabase
.from("tasks")
.select("id, status, created_at, due_at"),
supabase
.from("projects")
.select("id, name"),
supabase
.from("finance_transactions")
.select("id, type, amount, transaction_date, project_id"),
]);
const range = typeof searchParams.range === "string" ? searchParams.range : "this_month";
const now = new Date();
let startDate = new Date();
let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
if (range === "this_week") {
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 metrics using RPC
const { data: metricsData } = await supabase.rpc('get_analytics_metrics', {
p_start_date: startDate.toISOString(),
p_end_date: endDate.toISOString()
});
const analyticsData = {
tasks: tasks || [],
projects: projects || [],
finances: finances || [],
metrics: metricsData || {
projectIncomeData: [],
completedTasks: 0,
activeTasks: 0
},
range
};
return <AnalyticsClient data={analyticsData} />;
+29 -56
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { PendingLink } from "@/components/ui/pending-link";
import { Badge, Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
@@ -8,11 +8,17 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
import { CheckCircle2, Wallet, FolderKanban, Activity, Users } from "lucide-react";
export type DashboardData = {
tasks: { id: string; status: string; created_at: string; updated_at?: string; due_at?: string }[];
metrics: {
netProfit: number;
activeProjectsCount: number;
completedTasksCount: number;
avgMood: string;
financeTrend: { date: string; income: number; expense: number }[];
moodTrend: { date: string; mood: number; energy: number }[];
};
projects: { id: string; status: string; name: string; created_at: string }[];
finances: { id: string; type: string; amount: number; transaction_date: string }[];
logs: { id: string; log_date: string; mood_score: number; energy_score: number }[];
clients: { id: string; name: string; company_name: string; created_at: string }[];
range: string;
};
type DashboardClientProps = {
@@ -20,62 +26,29 @@ type DashboardClientProps = {
};
export function DashboardClient({ data }: DashboardClientProps) {
const [dateRange, setDateRange] = useState("this_month");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Calculate real metrics from the `data` prop depending on `dateRange`
const now = new Date();
// Helper to filter by date
const filterByDate = (dateStr: string | null) => {
if (!dateStr) return false;
const date = new Date(dateStr);
if (dateRange === "today") {
return date.toDateString() === now.toDateString();
}
if (dateRange === "this_week") {
const firstDay = new Date(now.setDate(now.getDate() - now.getDay() + (now.getDay() === 0 ? -6 : 1))); // Monday
return date >= firstDay;
}
if (dateRange === "this_month") {
return date.getMonth() === now.getMonth() && 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()}`);
};
// KPIs
const filteredFinances = (data.finances || []).filter(f => filterByDate(f.transaction_date));
const income = filteredFinances.filter(f => f.type === "income").reduce((acc, curr) => acc + Number(curr.amount), 0);
const expense = filteredFinances.filter(f => f.type === "expense").reduce((acc, curr) => acc + Number(curr.amount), 0);
const netProfit = income - expense;
const { netProfit, activeProjectsCount, completedTasksCount, avgMood, financeTrend, moodTrend } = data.metrics;
const activeProjectsCount = (data.projects || []).filter(p => p.status === "active").length;
// Format dates for Recharts using local timezone
const incomeTrendData = (financeTrend || []).map(f => ({
name: new Date(f.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
income: f.income,
expense: f.expense
}));
const completedTasksCount = (data.tasks || []).filter(t => t.status === "done" && filterByDate(t.updated_at || t.created_at)).length;
const filteredLogs = (data.logs || []).filter(l => filterByDate(l.log_date));
const avgMood = filteredLogs.length > 0
? (filteredLogs.reduce((acc, curr) => acc + curr.mood_score, 0) / filteredLogs.length).toFixed(1)
: "0.0";
// Recharts Data Prep
// Group finances by date
const financeMap = new Map();
filteredFinances.forEach(f => {
const d = new Date(f.transaction_date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" });
if (!financeMap.has(d)) {
financeMap.set(d, { name: d, income: 0, expense: 0 });
}
const entry = financeMap.get(d);
if (f.type === "income") entry.income += Number(f.amount);
if (f.type === "expense") entry.expense += Number(f.amount);
});
const incomeTrendData = Array.from(financeMap.values());
// Group logs by date
const moodTrendData = filteredLogs.map(l => ({
date: new Date(l.log_date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
mood: l.mood_score,
energy: l.energy_score,
const moodTrendData = (moodTrend || []).map(l => ({
date: new Date(l.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }),
mood: l.mood,
energy: l.energy,
}));
// Format currency
@@ -107,7 +80,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</div>
<div className="flex items-center gap-3">
<Select value={dateRange} onValueChange={setDateRange}>
<Select value={data.range} onValueChange={handleRangeChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tarih aralığı" />
</SelectTrigger>
+47 -30
View File
@@ -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
// Fetch all dashboard data in parallel with only needed columns
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 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 <DashboardClient data={dashboardData} />;
+3 -1
View File
@@ -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
@@ -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;
$$;
+169 -1
View File
@@ -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';