Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/src/db/queries/analytics/analytics.queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
Comment on lines +23 to +34
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
`,
};
18 changes: 17 additions & 1 deletion backend/src/modules/analytics/analytics.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
12 changes: 12 additions & 0 deletions backend/src/modules/analytics/analytics.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
},
};
5 changes: 5 additions & 0 deletions backend/src/modules/expenses/expenses.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 20 additions & 15 deletions frontend/src/api/expenseService.js
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions frontend/src/app/router.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -74,6 +75,14 @@ export function AppRouter() {
</ProtectedRoute>
}
/>
<Route
path="/my-approvals"
element={
<ProtectedRoute allowedRoles={["employee"]}>
<MyApprovalsPage />
</ProtectedRoute>
}
/>
<Route
path="/analytics"
element={
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/layout/AppLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useNotifications } from "../../context/NotificationContext.jsx";
import NotificationBell from "../../features/notifications/components/NotificationBell.jsx";
import ToastStack from "../../features/notifications/components/ToastStack.jsx";
import {
CircleCheckBig,
LayoutDashboard,
FileCheck,
Receipt,
Expand All @@ -30,6 +31,12 @@ const NAV_ITEMS = [
icon: ShieldCheck,
roles: ["admin"],
},
{
path: "/my-approvals",
label: "My Approvals",
icon: CircleCheckBig,
roles: ["employee"],
},
];

export default function AppLayout() {
Expand Down
76 changes: 29 additions & 47 deletions frontend/src/features/analytics/components/ApprovalRateChart.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, Legend } from "recharts";
import { ANALYTICS_APPROVAL_RATE } from "../../../utils/mockData.js";

export default function ApprovalRateChart() {
export default function ApprovalRateChart({ data = [] }) {
const chartData = data.map((item) => ({
month: item.month,
approved: Number(item.approved || 0),
rejected: Number(item.rejected || 0),
}));

return (
<div className="ethereal-card">
<h3 className="font-manrope font-bold text-base text-forest-900 mb-1">
Expand All @@ -11,50 +16,27 @@ export default function ApprovalRateChart() {
Monthly approved vs rejected expenses
</p>

<ResponsiveContainer width="100%" height={280}>
<BarChart data={ANALYTICS_APPROVAL_RATE} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#eff1f0" vertical={false} />
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }}
/>
<Tooltip
contentStyle={{
background: "white",
border: "none",
borderRadius: "12px",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.1)",
padding: "10px 14px",
fontSize: "13px",
fontFamily: "Inter",
}}
/>
<Legend
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: "12px", fontFamily: "Inter" }}
/>
<Bar
dataKey="approved"
fill="#00FF66"
radius={[6, 6, 0, 0]}
name="Approved"
/>
<Bar
dataKey="rejected"
fill="#f87171"
radius={[6, 6, 0, 0]}
name="Rejected"
/>
</BarChart>
</ResponsiveContainer>
{chartData.length === 0 ? (
<p className="text-sm text-surface-500">No approval data yet.</p>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#eff1f0" vertical={false} />
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }} />
<Tooltip
contentStyle={{
background: "white", border: "none", borderRadius: "12px",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.1)", padding: "10px 14px",
fontSize: "13px", fontFamily: "Inter",
}}
/>
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: "12px", fontFamily: "Inter" }} />
<Bar dataKey="approved" fill="#00FF66" radius={[6, 6, 0, 0]} name="Approved" />
<Bar dataKey="rejected" fill="#f87171" radius={[6, 6, 0, 0]} name="Rejected" />
</BarChart>
</ResponsiveContainer>
)}
</div>
);
}
}
56 changes: 27 additions & 29 deletions frontend/src/features/analytics/components/MonthlyVelocityChart.jsx
Original file line number Diff line number Diff line change
@@ -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),
}));
Comment on lines +4 to +7

if (mini) {
return (
<ResponsiveContainer width="100%" height={60}>
<BarChart data={ANALYTICS_MONTHLY_VELOCITY} barSize={16}>
<BarChart data={chartData} barSize={16}>
<Bar dataKey="amount" fill="rgba(255,255,255,0.4)" radius={[4, 4, 0, 0]} />
<XAxis
dataKey="day"
Expand All @@ -24,33 +28,27 @@ export default function MonthlyVelocityChart({ mini = false }) {
Monthly Velocity
</h3>
<p className="text-xs text-surface-500 mb-6">
Daily spending velocity this week
Daily spending velocity, last 30 days
</p>

<ResponsiveContainer width="100%" height={240}>
<BarChart data={ANALYTICS_MONTHLY_VELOCITY} barSize={32}>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }}
/>
<Tooltip
contentStyle={{
background: "white",
border: "none",
borderRadius: "12px",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.1)",
padding: "10px 14px",
fontSize: "13px",
fontFamily: "Inter",
}}
formatter={(value) => [`$${value.toLocaleString()}`, "Spend"]}
/>
<Bar dataKey="amount" fill="#1A4D2E" radius={[8, 8, 0, 0]}>
</Bar>
</BarChart>
</ResponsiveContainer>
{chartData.length === 0 ? (
<p className="text-sm text-surface-500">No recent activity.</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chartData} barSize={32}>
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#999", fontFamily: "Inter" }} />
<Tooltip
contentStyle={{
background: "white", border: "none", borderRadius: "12px",
boxShadow: "0 4px 16px rgba(26, 77, 46, 0.1)", padding: "10px 14px",
fontSize: "13px", fontFamily: "Inter",
}}
formatter={(value) => [`$${value.toLocaleString()}`, "Spend"]}
/>
<Bar dataKey="amount" fill="#1A4D2E" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
);
}
}
Loading