diff --git a/backend/src/db/queries/analytics/analytics.queries.js b/backend/src/db/queries/analytics/analytics.queries.js index e16f070..b5b6517 100644 --- a/backend/src/db/queries/analytics/analytics.queries.js +++ b/backend/src/db/queries/analytics/analytics.queries.js @@ -13,4 +13,45 @@ FROM expenses WHERE company_id = $1 GROUP BY 1`, + turnaroundTime: ` + SELECT + ROUND(AVG(EXTRACT(EPOCH FROM (updated_at - submitted_at)) / 86400)::numeric, 1) AS avg_days + FROM expenses + WHERE company_id = $1 + AND status IN ('approved', 'rejected') + `, + riskTrend: ` + SELECT + TO_CHAR(submitted_at, 'Mon') AS month, + EXTRACT(MONTH FROM submitted_at) AS month_num, + COUNT(*) FILTER (WHERE converted_amount < 1000) AS low, + COUNT(*) FILTER (WHERE converted_amount >= 1000 AND converted_amount < 5000) AS medium, + COUNT(*) FILTER (WHERE converted_amount >= 5000) AS high + FROM expenses + WHERE company_id = $1 + GROUP BY month, month_num + ORDER BY month_num +`, + approvalRate: ` + SELECT + TO_CHAR(submitted_at, 'Mon') AS month, + EXTRACT(MONTH FROM submitted_at) AS month_num, + COUNT(*) FILTER (WHERE status = 'approved') AS approved, + COUNT(*) FILTER (WHERE status = 'rejected') AS rejected + FROM expenses + WHERE company_id = $1 + GROUP BY month, month_num + ORDER BY month_num +`, + monthlyVelocity: ` + SELECT + TO_CHAR(submitted_at, 'Dy') AS day, + submitted_at::date AS date, + SUM(converted_amount) AS amount + FROM expenses + WHERE company_id = $1 + AND submitted_at >= NOW() - INTERVAL '30 days' + GROUP BY day, date + ORDER BY date +`, }; diff --git a/backend/src/modules/analytics/analytics.controller.js b/backend/src/modules/analytics/analytics.controller.js index f9ca92a..c85da75 100644 --- a/backend/src/modules/analytics/analytics.controller.js +++ b/backend/src/modules/analytics/analytics.controller.js @@ -3,14 +3,30 @@ import { analyticsModel } from "./analytics.model.js"; export const summary = asyncHandler(async (req, res) => { const companyId = req.user.companyId; - const [byCategory, byUser, risk] = await Promise.all([ + const [ + byCategory, + byUser, + risk, + turnaround, + riskTrend, + approvalRate, + monthlyVelocity, + ] = await Promise.all([ analyticsModel.byCategory(companyId), analyticsModel.byUser(companyId), analyticsModel.riskSummary(companyId), + analyticsModel.turnaroundTime(companyId), + analyticsModel.riskTrend(companyId), + analyticsModel.approvalRate(companyId), + analyticsModel.monthlyVelocity(companyId), ]); res.json({ byCategory: byCategory.rows, byUser: byUser.rows, risk: risk.rows, + turnaround: turnaround.rows[0], + riskTrend: riskTrend.rows, + approvalRate: approvalRate.rows, + monthlyVelocity: monthlyVelocity.rows, }); }); diff --git a/backend/src/modules/analytics/analytics.model.js b/backend/src/modules/analytics/analytics.model.js index a1352c0..5c075bf 100644 --- a/backend/src/modules/analytics/analytics.model.js +++ b/backend/src/modules/analytics/analytics.model.js @@ -11,4 +11,16 @@ export const analyticsModel = { riskSummary(companyId) { return query(analyticsQueries.riskSummary, [companyId]); }, + turnaroundTime(companyId) { + return query(analyticsQueries.turnaroundTime, [companyId]); + }, + riskTrend(companyId) { + return query(analyticsQueries.riskTrend, [companyId]); + }, + approvalRate(companyId) { + return query(analyticsQueries.approvalRate, [companyId]); + }, + monthlyVelocity(companyId) { + return query(analyticsQueries.monthlyVelocity, [companyId]); + }, }; diff --git a/backend/src/modules/expenses/expenses.controller.js b/backend/src/modules/expenses/expenses.controller.js index 73f9850..dc231a6 100644 --- a/backend/src/modules/expenses/expenses.controller.js +++ b/backend/src/modules/expenses/expenses.controller.js @@ -346,6 +346,11 @@ export async function getExpenseApprovalStatus(req, res) { return fail(res, 404, "Expense not found"); } + const role = String(req.user.role || "").toLowerCase(); + if (role === "employee" && expense.user_id !== req.user.userId) { + return fail(res, 403, "Not authorized to view this expense"); + } + const timeline = await getApprovalTimeline(id, pool); const pendingApprovers = await getPendingApprovers(id, pool); const approvedCount = timeline.filter( diff --git a/frontend/src/api/expenseService.js b/frontend/src/api/expenseService.js index 172c635..e619f96 100644 --- a/frontend/src/api/expenseService.js +++ b/frontend/src/api/expenseService.js @@ -1,31 +1,36 @@ import api from "./axios"; export async function getExpenses(params = {}) { - const { data } = await api.get("/expenses", { params }); - return data?.data || []; + const { data } = await api.get("/expenses", { params }); + return data?.data || []; } export async function getExpenseById(id) { - try { - const { data } = await api.get(`/expenses/${id}`); - return data?.data || data; - } catch { - const { data } = await api.get(`/expenses/${id}/approval-status`); - return data?.data || data; - } + try { + const { data } = await api.get(`/expenses/${id}`); + return data?.data || data; + } catch { + const { data } = await api.get(`/expenses/${id}/approval-status`); + return data?.data || data; + } } export async function createExpense(payload) { - const { data } = await api.post("/expenses", payload); - return data?.data || data; + const { data } = await api.post("/expenses", payload); + return data?.data || data; } export async function approveExpense(id, payload = {}) { - const { data } = await api.patch(`/expenses/${id}/approve`, payload); - return data?.data || data; + const { data } = await api.patch(`/expenses/${id}/approve`, payload); + return data?.data || data; } export async function rejectExpense(id, payload) { - const { data } = await api.patch(`/expenses/${id}/reject`, payload); - return data?.data || data; + const { data } = await api.patch(`/expenses/${id}/reject`, payload); + return data?.data || data; +} + +export async function getApprovalStatus(id) { + const { data } = await api.get(`/expenses/${id}/approval-status`); + return data?.data || data; } diff --git a/frontend/src/app/router.jsx b/frontend/src/app/router.jsx index f5d3f6c..714e8b8 100644 --- a/frontend/src/app/router.jsx +++ b/frontend/src/app/router.jsx @@ -11,6 +11,7 @@ import AnalyticsPage from "../pages/AnalyticsPage.jsx"; import AdminPanelPage from "../pages/AdminPanelPage.jsx"; import NotFoundPage from "../pages/NotFoundPage.jsx"; import UnauthorizedPage from "../pages/UnauthorizedPage.jsx"; +import MyApprovalsPage from "../pages/MyApprovalsPage.jsx"; function RoleHomeRedirect() { const { user } = useAuth(); @@ -74,6 +75,14 @@ export function AppRouter() { } /> + + + + } + /> ({ + month: item.month, + approved: Number(item.approved || 0), + rejected: Number(item.rejected || 0), + })); + return (

@@ -11,50 +16,27 @@ export default function ApprovalRateChart() { Monthly approved vs rejected expenses

- - - - - - - - - - - + {chartData.length === 0 ? ( +

No approval data yet.

+ ) : ( + + + + + + + + + + + + )}

); -} +} \ No newline at end of file diff --git a/frontend/src/features/analytics/components/MonthlyVelocityChart.jsx b/frontend/src/features/analytics/components/MonthlyVelocityChart.jsx index c22a1cc..04b6537 100644 --- a/frontend/src/features/analytics/components/MonthlyVelocityChart.jsx +++ b/frontend/src/features/analytics/components/MonthlyVelocityChart.jsx @@ -1,11 +1,15 @@ import { BarChart, Bar, XAxis, ResponsiveContainer, Tooltip } from "recharts"; -import { ANALYTICS_MONTHLY_VELOCITY } from "../../../utils/mockData.js"; -export default function MonthlyVelocityChart({ mini = false }) { +export default function MonthlyVelocityChart({ data = [], mini = false }) { + const chartData = data.map((item) => ({ + day: item.day, + amount: Number(item.amount || 0), + })); + if (mini) { return ( - +

- Daily spending velocity this week + Daily spending velocity, last 30 days

- - - - [`$${value.toLocaleString()}`, "Spend"]} - /> - - - - + {chartData.length === 0 ? ( +

No recent activity.

+ ) : ( + + + + [`$${value.toLocaleString()}`, "Spend"]} + /> + + + + )} ); -} +} \ No newline at end of file diff --git a/frontend/src/features/analytics/components/RiskScoreTrendChart.jsx b/frontend/src/features/analytics/components/RiskScoreTrendChart.jsx index 22b71cb..05583ef 100644 --- a/frontend/src/features/analytics/components/RiskScoreTrendChart.jsx +++ b/frontend/src/features/analytics/components/RiskScoreTrendChart.jsx @@ -1,75 +1,43 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; -const RISK_DATA = [ - { month: "Jun", low: 35, medium: 8, high: 2 }, - { month: "Jul", low: 30, medium: 12, high: 4 }, - { month: "Aug", low: 42, medium: 6, high: 1 }, - { month: "Sep", low: 38, medium: 9, high: 3 }, - { month: "Oct", low: 50, medium: 7, high: 2 }, - { month: "Nov", low: 45, medium: 5, high: 1 }, -]; +export default function RiskScoreTrendChart({ data = [] }) { + const chartData = data.map((item) => ({ + month: item.month, + low: Number(item.low || 0), + medium: Number(item.medium || 0), + high: Number(item.high || 0), + })); -export default function RiskScoreTrendChart() { return (

Risk Score Trend

- AI risk level distribution over time + Risk level distribution over time

- - - - - - - - - - - + {chartData.length === 0 ? ( +

No trend data yet.

+ ) : ( + + + + + + + + + + + + )}
); -} +} \ No newline at end of file diff --git a/frontend/src/features/analytics/components/SpendByCategoryChart.jsx b/frontend/src/features/analytics/components/SpendByCategoryChart.jsx index afb7437..337e32f 100644 --- a/frontend/src/features/analytics/components/SpendByCategoryChart.jsx +++ b/frontend/src/features/analytics/components/SpendByCategoryChart.jsx @@ -1,7 +1,25 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"; -import { ANALYTICS_CATEGORY_SPEND } from "../../../utils/mockData.js"; -export default function SpendByCategoryChart() { +const COLORS = ["#1A4D2E", "#00FF66", "#F59E0B", "#3B82F6", "#EF4444", "#8B5CF6"]; + +export default function SpendByCategoryChart({ data = [] }) { + const chartData = data.map((item, index) => ({ + name: item.category, + value: Number(item.total || 0), + color: COLORS[index % COLORS.length], + })); + + if (chartData.length === 0) { + return ( +
+

+ Spend by Category +

+

No category data yet.

+
+ ); + } + return (

@@ -14,7 +32,7 @@ export default function SpendByCategoryChart() { - {ANALYTICS_CATEGORY_SPEND.map((entry, index) => ( + {chartData.map((entry, index) => ( ))} @@ -43,14 +61,10 @@ export default function SpendByCategoryChart() { verticalAlign="bottom" iconType="circle" iconSize={8} - wrapperStyle={{ - fontSize: "12px", - fontFamily: "Inter", - color: "#666", - }} + wrapperStyle={{ fontSize: "12px", fontFamily: "Inter", color: "#666" }} />

); -} +} \ No newline at end of file diff --git a/frontend/src/features/analytics/components/TurnaroundTimeCard.jsx b/frontend/src/features/analytics/components/TurnaroundTimeCard.jsx index 4b2f81d..c5d1cdb 100644 --- a/frontend/src/features/analytics/components/TurnaroundTimeCard.jsx +++ b/frontend/src/features/analytics/components/TurnaroundTimeCard.jsx @@ -1,8 +1,7 @@ -import { TrendingDown, Clock } from "lucide-react"; -import { ANALYTICS_TURNAROUND } from "../../../utils/mockData.js"; +import { Clock } from "lucide-react"; -export default function TurnaroundTimeCard() { - const { averageDays, trend, trendLabel } = ANALYTICS_TURNAROUND; +export default function TurnaroundTimeCard({ avgDays = 0 }) { + const days = Number(avgDays || 0); return (
@@ -23,24 +22,16 @@ export default function TurnaroundTimeCard() {

- {averageDays} + {days}

days

- -
- - {Math.abs(trend)} days -
-

{trendLabel}

- - {/* Mini bar */}
@@ -49,4 +40,4 @@ export default function TurnaroundTimeCard() {
); -} +} \ No newline at end of file diff --git a/frontend/src/features/expense/components/RecentSpendingCards.jsx b/frontend/src/features/expense/components/RecentSpendingCards.jsx index 86305dc..80dd7f2 100644 --- a/frontend/src/features/expense/components/RecentSpendingCards.jsx +++ b/frontend/src/features/expense/components/RecentSpendingCards.jsx @@ -1,16 +1,41 @@ -import { RECENT_EXPENSES, CATEGORIES } from "../../../utils/mockData.js"; +import { useEffect, useState } from "react"; +import { getExpenses } from "../../../api/expenseService.js"; import { ArrowRight } from "lucide-react"; -const CATEGORY_COLORS = { - dining: { bg: "bg-amber-50", tag: "bg-amber-100 text-amber-700" }, - travel: { bg: "bg-blue-50", tag: "bg-blue-100 text-blue-700" }, - supplies: { bg: "bg-purple-50", tag: "bg-purple-100 text-purple-700" }, - tech: { bg: "bg-cyan-50", tag: "bg-cyan-100 text-cyan-700" }, - operations: { bg: "bg-emerald-50", tag: "bg-emerald-100 text-emerald-700" }, - transport: { bg: "bg-pink-50", tag: "bg-pink-100 text-pink-700" }, +const CATEGORY_STYLE = { + Travel: { bg: "bg-blue-50", tag: "bg-blue-100 text-blue-700", emoji: "✈️" }, + Food: { bg: "bg-amber-50", tag: "bg-amber-100 text-amber-700", emoji: "🍽️" }, + Office: { bg: "bg-purple-50", tag: "bg-purple-100 text-purple-700", emoji: "📦" }, + Other: { bg: "bg-cyan-50", tag: "bg-cyan-100 text-cyan-700", emoji: "💼" }, }; +function timeAgo(dateStr) { + if (!dateStr) return ""; + const diffMs = Date.now() - new Date(dateStr).getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + export default function RecentSpendingCards() { + const [expenses, setExpenses] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + getExpenses() + .then((rows) => { + if (!active) return; + setExpenses((Array.isArray(rows) ? rows : []).slice(0, 3)); + }) + .catch(() => {}) + .finally(() => active && setLoading(false)); + return () => { active = false; }; + }, []); + return (
@@ -28,58 +53,56 @@ export default function RecentSpendingCards() {
-
- {RECENT_EXPENSES.map((expense) => { - const cat = CATEGORY_COLORS[expense.category] || CATEGORY_COLORS.tech; - const categoryLabel = CATEGORIES.find((c) => c.id === expense.category)?.label || expense.category; + {loading &&

Loading...

} + {!loading && expenses.length === 0 && ( +

No expenses submitted yet.

+ )} + + {!loading && expenses.length > 0 && ( +
+ {expenses.map((expense) => { + const cat = CATEGORY_STYLE[expense.category] || CATEGORY_STYLE.Other; + const amount = Number(expense.converted_amount || expense.amount || 0); + const currency = expense.base_currency || expense.currency || "USD"; - return ( -
- {/* Category Image Area */} -
-
- {expense.category === "dining" && "🍽️"} - {expense.category === "travel" && "✈️"} - {expense.category === "supplies" && "📦"} - {expense.category === "tech" && "💻"} - {expense.category === "operations" && "⚙️"} + return ( +
+
+
+ {cat.emoji} +
+ + {expense.category} +
- - {categoryLabel} - -
- {/* Details */} -
-
-

- {expense.title} -

-

- {expense.description} +

+
+

+ {expense.vendor || "Expense"} +

+

+ {expense.description || "-"} +

+
+

+ {currency} {amount.toFixed(2)}

-

- ${expense.amount.toFixed(2)} -

-
- {/* Time badge */} -
-
- - {expense.timeAgo} - +
+
+ + {timeAgo(expense.submitted_at)} + +
-
- ); - })} -
+ ); + })} +
+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/AnalyticsPage.jsx b/frontend/src/pages/AnalyticsPage.jsx index 7be953f..1cbf8d7 100644 --- a/frontend/src/pages/AnalyticsPage.jsx +++ b/frontend/src/pages/AnalyticsPage.jsx @@ -13,6 +13,10 @@ export default function AnalyticsPage() { byCategory: [], byUser: [], risk: [], + riskTrend: [], + approvalRate: [], + monthlyVelocity: [], + turnaround: null, }); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -29,6 +33,10 @@ export default function AnalyticsPage() { byCategory: data?.byCategory || [], byUser: data?.byUser || [], risk: data?.risk || [], + riskTrend: data?.riskTrend || [], + approvalRate: data?.approvalRate || [], + monthlyVelocity: data?.monthlyVelocity || [], + turnaround: data?.turnaround || null, }); }) .catch((err) => { @@ -132,16 +140,16 @@ export default function AnalyticsPage() { {/* Charts Grid */}
- - + +
- - + +
- +
); } diff --git a/frontend/src/pages/MyApprovalsPage.jsx b/frontend/src/pages/MyApprovalsPage.jsx new file mode 100644 index 0000000..ecb797f --- /dev/null +++ b/frontend/src/pages/MyApprovalsPage.jsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from "react"; +import { getExpenses, getApprovalStatus } from "../api/expenseService.js"; +import ApprovalStepper from "../features/approval/components/ApprovalStepper.jsx"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleDateString(); +} + +function toFullSteps(timeline = []) { + return timeline.map((step) => ({ + step: step.sequence, + role: step.approver_name || `Approver ${step.sequence}`, + name: + step.status === "approved" + ? `Approved${step.comment ? ` — "${step.comment}"` : ""}` + : step.status === "rejected" + ? `Rejected — "${step.comment || "No reason given"}"` + : "Awaiting decision", + description: step.acted_at ? formatDate(step.acted_at) : null, + status: String(step.status || "pending").toUpperCase(), + })); +} + +export default function MyApprovalsPage() { + const [expenses, setExpenses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [expandedId, setExpandedId] = useState(null); + const [detailCache, setDetailCache] = useState({}); + const [detailLoading, setDetailLoading] = useState(false); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + + getExpenses() + .then((rows) => { + if (!active) return; + setExpenses(Array.isArray(rows) ? rows : []); + }) + .catch((err) => { + if (!active) return; + setError( + err?.response?.data?.error || + err?.response?.data?.message || + "Failed to load expenses", + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, []); + + async function toggleExpand(id) { + if (expandedId === id) { + setExpandedId(null); + return; + } + + setExpandedId(id); + + if (!detailCache[id]) { + setDetailLoading(true); + try { + const detail = await getApprovalStatus(id); + setDetailCache((prev) => ({ ...prev, [id]: detail })); + } catch (err) { + setDetailCache((prev) => ({ + ...prev, + [id]: { error: "Failed to load approval details" }, + })); + } finally { + setDetailLoading(false); + } + } + } + + return ( +
+

+ My Approvals +

+ + {loading && ( +
Loading expenses...
+ )} + + {!loading && error && ( +
{error}
+ )} + + {!loading && !error && expenses.length === 0 && ( +
+ No expenses submitted yet. +
+ )} + + {!loading && + !error && + expenses.map((expense) => { + const isExpanded = expandedId === expense.id; + const detail = detailCache[expense.id]; + const amount = Number( + expense.converted_amount || expense.amount || 0, + ); + const currency = + expense.base_currency || expense.currency || "USD"; + + return ( +
+ + + {isExpanded && ( +
+ {detailLoading && !detail && ( +

+ Loading approval details... +

+ )} + {detail?.error && ( +

{detail.error}

+ )} + {detail && !detail.error && ( + <> +

+ {detail.approvedCount} of {detail.totalSteps} approvals +

+ + + )} +
+ )} +
+ ); + })} +
+ ); +} \ No newline at end of file