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)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index 4c2cbda..44a15fc 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -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 ; diff --git a/app/(dashboard)/dashboard-client.tsx b/app/(dashboard)/dashboard-client.tsx index b6d621f..03a5ff3 100644 --- a/app/(dashboard)/dashboard-client.tsx +++ b/app/(dashboard)/dashboard-client.tsx @@ -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"); - - // Calculate real metrics from the `data` prop depending on `dateRange` - const now = new Date(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - // 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; - - const completedTasksCount = (data.tasks || []).filter(t => t.status === "done" && filterByDate(t.updated_at || t.created_at)).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 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) {
- 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';