From 1505b4a6d57f9231d4e28cb91474a4646bd5be07 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 10:59:57 +0530 Subject: [PATCH 1/6] Updated error handling mechanism and logged it in routes --- backend/src/middleware/errorHandler.js | 5 ++++- backend/src/routes/index.js | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index cd91fb5..967eeb7 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -2,7 +2,10 @@ export const errorHandler = (err, _req, res, _next) => { if (err instanceof ApiError) { - return res.status(err.statusCode).json({ message: err.message, details: err.details }); + return res + .status(err.statusCode) + .json({ message: err.message, details: err.details }); } + console.error(err); return res.status(500).json({ message: "Internal server error" }); }; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 3903c1f..9ec5e37 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -5,12 +5,14 @@ import companyRoutes from "../modules/companies/companies.routes.js"; import expenseRoutes from "../modules/expenses/expenses.routes.js"; import analyticsRoutes from "../modules/analytics/analytics.routes.js"; import notificationRoutes from "../modules/notifications/notifications.routes.js"; +import approvalRulesRoutes from "../modules/approvalRules/approvalRules.routes.js"; //these are routes const router = Router(); router.use("/auth", authRoutes); router.use("/users", userRoutes); +router.use("/approval-rules", approvalRulesRoutes); router.use("/companies", companyRoutes); router.use("/expenses", expenseRoutes); router.use("/analytics", analyticsRoutes); From e13a30e34ef74aaefe45ce3efe3bae8caa15e383 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 11:01:41 +0530 Subject: [PATCH 2/6] New reimbursement approval handling logic --- .../approvalRules/approvalRules.controller.js | 39 +++++++++++++++++++ .../approvalRules/approvalRules.model.js | 33 ++++++++++++++++ .../approvalRules/approvalRules.routes.js | 17 ++++++++ 3 files changed, 89 insertions(+) create mode 100644 backend/src/modules/approvalRules/approvalRules.controller.js create mode 100644 backend/src/modules/approvalRules/approvalRules.model.js create mode 100644 backend/src/modules/approvalRules/approvalRules.routes.js diff --git a/backend/src/modules/approvalRules/approvalRules.controller.js b/backend/src/modules/approvalRules/approvalRules.controller.js new file mode 100644 index 0000000..4c1f7b1 --- /dev/null +++ b/backend/src/modules/approvalRules/approvalRules.controller.js @@ -0,0 +1,39 @@ +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { approvalRulesModel } from "./approvalRules.model.js"; + +const VALID_TYPES = ["sequential", "percentage", "hybrid"]; + +export const getCurrentRule = asyncHandler(async (req, res) => { + const companyId = req.user.companyId; + const result = await approvalRulesModel.getLatestByCompany(companyId); + + if (!result.rows[0]) { + return res.status(404).json({ message: "No approval rule configured" }); + } + + res.json(result.rows[0]); +}); + +export const getRuleHistory = asyncHandler(async (req, res) => { + const companyId = req.user.companyId; + const result = await approvalRulesModel.getHistoryByCompany(companyId); + res.json(result.rows); +}); + +export const saveRule = asyncHandler(async (req, res) => { + const companyId = req.user.companyId; + const { type, config } = req.body; + + if (!VALID_TYPES.includes(type)) { + return res.status(400).json({ + message: `type must be one of: ${VALID_TYPES.join(", ")}`, + }); + } + + if (!config || typeof config !== "object" || Array.isArray(config)) { + return res.status(400).json({ message: "config must be an object" }); + } + + const result = await approvalRulesModel.create({ companyId, type, config }); + res.status(201).json(result.rows[0]); +}); diff --git a/backend/src/modules/approvalRules/approvalRules.model.js b/backend/src/modules/approvalRules/approvalRules.model.js new file mode 100644 index 0000000..321f12c --- /dev/null +++ b/backend/src/modules/approvalRules/approvalRules.model.js @@ -0,0 +1,33 @@ +import { query } from "../../config/db.js"; + +export const approvalRulesModel = { + async getLatestByCompany(companyId) { + return query( + `SELECT id, company_id, type, config, created_at + FROM approval_rules + WHERE company_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [companyId], + ); + }, + + async getHistoryByCompany(companyId) { + return query( + `SELECT id, company_id, type, config, created_at + FROM approval_rules + WHERE company_id = $1 + ORDER BY created_at DESC`, + [companyId], + ); + }, + + async create({ companyId, type, config }) { + return query( + `INSERT INTO approval_rules (company_id, type, config) + VALUES ($1, $2, $3) + RETURNING id, company_id, type, config, created_at`, + [companyId, type, config], + ); + }, +}; diff --git a/backend/src/modules/approvalRules/approvalRules.routes.js b/backend/src/modules/approvalRules/approvalRules.routes.js new file mode 100644 index 0000000..5dba98a --- /dev/null +++ b/backend/src/modules/approvalRules/approvalRules.routes.js @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { authenticate } from "../../middleware/authenticate.js"; +import { authorize } from "../../middleware/authorize.js"; +import { + getCurrentRule, + getRuleHistory, + saveRule, +} from "./approvalRules.controller.js"; + +const router = Router(); +router.use(authenticate); + +router.get("/", authorize("admin"), getCurrentRule); +router.get("/history", authorize("admin"), getRuleHistory); +router.put("/", authorize("admin"), saveRule); + +export default router; From aa4456e94c448fcd9ec560f737acd3ba589bb768 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 11:02:10 +0530 Subject: [PATCH 3/6] Updates --- backend/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index bbbbe79..2122fd5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ NODE_ENV=development -PORT=3000 +PORT=5000 FRONTEND_URL=http://localhost:5173 JWT_SECRET=change_me DATABASE_URL=DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres From eeaac5f8c83d1953e0900d180a9d862343d56a05 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 11:03:02 +0530 Subject: [PATCH 4/6] New approval frontend logic --- frontend/src/api/approvalService.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/approvalService.js b/frontend/src/api/approvalService.js index c9d6a34..c7681cc 100644 --- a/frontend/src/api/approvalService.js +++ b/frontend/src/api/approvalService.js @@ -1,11 +1,16 @@ import api from "./axios"; export async function getPending() { - const { data } = await api.get("/expenses/pending"); - return data?.data || []; + const { data } = await api.get("/expenses/pending"); + return data?.data || []; } -export async function saveWorkflowRules(payload) { - const { data } = await api.put("/workflows/rules", payload); - return data?.rule || data; +export async function getApprovalRules() { + const { data } = await api.get("/approval-rules"); + return data; +} + +export async function saveApprovalRules(payload) { + const { data } = await api.put("/approval-rules", payload); + return data; } From d3a12a3eedb520670955a683b06a9ebbfc8f1b85 Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 11:03:36 +0530 Subject: [PATCH 5/6] Updates in the Admin Panel Page --- frontend/src/pages/AdminPanelPage.jsx | 614 ++++++++++++++------------ 1 file changed, 334 insertions(+), 280 deletions(-) diff --git a/frontend/src/pages/AdminPanelPage.jsx b/frontend/src/pages/AdminPanelPage.jsx index e694797..b11a16f 100644 --- a/frontend/src/pages/AdminPanelPage.jsx +++ b/frontend/src/pages/AdminPanelPage.jsx @@ -1,141 +1,170 @@ import { useEffect, useMemo, useState } from "react"; import ApprovalStepper from "../features/approval/components/ApprovalStepper.jsx"; import Button from "../components/ui/Button.jsx"; -import { saveWorkflowRules } from "../api/approvalService.js"; -import { getUsers } from "../api/userService.js"; -import { Settings, Users, Sparkles, X } from "lucide-react"; +import { getApprovalRules, saveApprovalRules } from "../api/approvalService.js"; +import { ListOrdered, Percent, GitBranch, X, Plus } from "lucide-react"; -const SUGGESTED_APPROVERS = [ - { id: 1, name: "Jordan D'Amico", role: "Head of Legal", initials: "JD" }, - { id: 2, name: "Elena Lopez", role: "Compliance Lead", initials: "EL" }, - { id: 3, name: "Raj Patel", role: "VP Engineering", initials: "RP" }, -]; +const ROLE_OPTIONS = ["manager", "finance", "director", "cfo"]; -const WORKFLOW_STEPS = [ +const RULE_TYPES = [ { - step: 1, - role: "Initiation", - name: "Employee submits ledger entry", - status: "APPROVED", + type: "sequential", + label: "Sequential", + icon: ListOrdered, + desc: "Each role approves in order, one after another.", }, { - step: 2, - role: "Direct Manager", - name: "Supervisor review required", - status: "CURRENT", + type: "percentage", + label: "Percentage", + icon: Percent, + desc: "All users of one role vote; a % threshold decides.", }, { - step: 3, - role: "Executive Consensus", - name: "Requires 60% approval score", - status: "PENDING", + type: "hybrid", + label: "Hybrid", + icon: GitBranch, + desc: "High-value expenses skip to an override approver.", }, ]; export default function AdminPanelPage() { - const [managerFirst, setManagerFirst] = useState(true); - const [threshold, setThreshold] = useState(60); - const [selectedApprovers, setSelectedApprovers] = useState([ - { id: 1, name: "Sarah Miller (CFO)", initials: "SM" }, - { id: 2, name: "Marcus Vane (VP Ops)", initials: "MV" }, - ]); - const [isDrafting, setIsDrafting] = useState(true); - const [availableApprovers, setAvailableApprovers] = - useState(SUGGESTED_APPROVERS); + const [loading, setLoading] = useState(true); const [saveLoading, setSaveLoading] = useState(false); const [saveMessage, setSaveMessage] = useState(""); const [saveError, setSaveError] = useState(""); + const [ruleType, setRuleType] = useState("sequential"); + + // sequential + const [sequentialRoles, setSequentialRoles] = useState(["manager", "finance"]); + const [roleToAdd, setRoleToAdd] = useState(ROLE_OPTIONS[0]); + + // percentage + const [pctRole, setPctRole] = useState("manager"); + const [pctThreshold, setPctThreshold] = useState(60); + + // hybrid + const [overrideRole, setOverrideRole] = useState("cfo"); + const [overrideThreshold, setOverrideThreshold] = useState(10000); + const [defaultFlow, setDefaultFlow] = useState(["manager", "finance"]); + const [hybridRoleToAdd, setHybridRoleToAdd] = useState(ROLE_OPTIONS[0]); + useEffect(() => { let active = true; - getUsers() - .then((response) => { - if (!active) return; - const users = Array.isArray(response?.users) ? response.users : []; - const mapped = users - .filter((user) => - ["manager", "admin"].includes( - String(user.role || "").toLowerCase(), - ), - ) - .map((user) => { - const name = user.name || "Approver"; - const initials = name - .split(" ") - .map((part) => part[0]) - .join("") - .toUpperCase() - .slice(0, 2); - return { - id: user.id, - name, - role: user.role, - initials, - }; - }); - if (mapped.length > 0) { - setAvailableApprovers(mapped); + getApprovalRules() + .then((rule) => { + if (!active || !rule) return; + setRuleType(rule.type); + const cfg = rule.config || {}; + if (rule.type === "sequential") { + setSequentialRoles(cfg.roles || cfg.sequence || ["manager", "finance"]); + } else if (rule.type === "percentage") { + setPctRole(cfg.approverRole || "manager"); + setPctThreshold(cfg.threshold ?? 60); + } else if (rule.type === "hybrid") { + setOverrideRole(cfg.overrideRole || "cfo"); + setOverrideThreshold(cfg.overrideThreshold ?? 10000); + setDefaultFlow(cfg.defaultFlow || ["manager", "finance"]); } }) .catch(() => { - // Keep local suggestions when users endpoint is unavailable. - }); - + // no rule yet — keep defaults, this is a first-time setup + }) + .finally(() => active && setLoading(false)); return () => { active = false; }; }, []); - const payloadApprovers = useMemo( - () => selectedApprovers.map((person) => person.id), - [selectedApprovers], - ); + function addSequentialRole() { + if (!sequentialRoles.includes(roleToAdd)) { + setSequentialRoles((prev) => [...prev, roleToAdd]); + } + } + function removeSequentialRole(role) { + setSequentialRoles((prev) => prev.filter((r) => r !== role)); + } + + function addHybridFlowRole() { + if (!defaultFlow.includes(hybridRoleToAdd)) { + setDefaultFlow((prev) => [...prev, hybridRoleToAdd]); + } + } + function removeHybridFlowRole(role) { + setDefaultFlow((prev) => prev.filter((r) => r !== role)); + } + + const payload = useMemo(() => { + if (ruleType === "sequential") { + return { type: "sequential", config: { roles: sequentialRoles } }; + } + if (ruleType === "percentage") { + return { + type: "percentage", + config: { approverRole: pctRole, threshold: Number(pctThreshold) }, + }; + } + return { + type: "hybrid", + config: { + overrideRole, + overrideThreshold: Number(overrideThreshold), + defaultFlow, + }, + }; + }, [ + ruleType, + sequentialRoles, + pctRole, + pctThreshold, + overrideRole, + overrideThreshold, + defaultFlow, + ]); - async function handleSaveWorkflow() { + const previewSteps = useMemo(() => { + if (ruleType === "sequential") { + return sequentialRoles.map((role, i) => ({ + step: i + 1, + role: role.charAt(0).toUpperCase() + role.slice(1), + status: "PENDING", + })); + } + if (ruleType === "hybrid") { + return defaultFlow.map((role, i) => ({ + step: i + 1, + role: role.charAt(0).toUpperCase() + role.slice(1), + status: "PENDING", + })); + } + return []; + }, [ruleType, sequentialRoles, defaultFlow]); + + async function handleSave() { setSaveLoading(true); setSaveMessage(""); setSaveError(""); - try { - await saveWorkflowRules({ - mode: threshold > 50 ? "hybrid" : "sequential", - percentage: threshold, - approvers: payloadApprovers, - managerFirstEnabled: managerFirst, - }); - setSaveMessage("Workflow rules saved successfully."); - setIsDrafting(false); + if (ruleType === "sequential" && sequentialRoles.length === 0) { + throw new Error("Add at least one approver role"); + } + await saveApprovalRules(payload); + setSaveMessage("Approval rule saved successfully."); } catch (err) { setSaveError( - err?.response?.data?.error || - err?.response?.data?.message || - "Failed to save workflow rules", + err?.response?.data?.message || err.message || "Failed to save rule", ); } finally { setSaveLoading(false); } } - function removeApprover(id) { - setSelectedApprovers((prev) => prev.filter((a) => a.id !== id)); - } - - function addApprover(approver) { - if (!selectedApprovers.find((a) => a.id === approver.id)) { - setSelectedApprovers((prev) => [...prev, approver]); - } + if (loading) { + return
Loading current rule...
; } - const thresholdLabel = - threshold <= 50 - ? "MAJORITY" - : threshold <= 75 - ? "SUPER MAJORITY" - : "UNANIMOUS"; - return (
- {/* Breadcrumb */}

Workflow Builder @@ -146,237 +175,262 @@ export default function AdminPanelPage() {

Approval Logic

-
- -
+
- {saveMessage && ( -

{saveMessage}

- )} + {saveMessage &&

{saveMessage}

} {saveError &&

{saveError}

}
- {/* Main Grid */}
- {/* Left Column */} + {/* Left column */}
- {/* Global Approval Rules */} + {/* Rule type picker */}
-
-
-

- Global Approval Rules -

-

- Define the fundamental behavior of your reimbursement chain. -

-
- {isDrafting && ( - - Drafting - - )} -
- - {/* Manager First Toggle */} -
-
-
-
- -
-
-

- Require Direct Manager Approval First -

-

- Forces initial review by the submitter's immediate - supervisor. -

-
-
- {/* Toggle Switch */} +

+ Approval Rule Type +

+

+ Choose how expenses get routed for approval. +

+
+ {RULE_TYPES.map(({ type, label, icon: Icon, desc }) => ( + ))} +
+
+ + {/* Sequential config */} + {ruleType === "sequential" && ( +
+

+ Approval Chain +

+

+ Roles approve in this exact order. +

+ +
+ {sequentialRoles.map((role, i) => ( +
+ {i + 1} + {role} + +
+ ))} + {sequentialRoles.length === 0 && ( +

No roles added yet.

+ )} +
+ +
+ +
+ )} - {/* Consensus Threshold */} -
-
-
-

- Consensus Threshold -

-

- Percentage of designated approvers required to finalize. -

-
-

- {threshold} - % -

+ {/* Percentage config */} + {ruleType === "percentage" && ( +
+

+ Voting Rule +

+

+ All users with the selected role vote in parallel. +

+ +
+ +
- {/* Slider */} -
-
-
+
+
+ +

+ {pctThreshold}% +

setThreshold(Number(e.target.value))} - className="absolute inset-0 w-full opacity-0 cursor-pointer" - /> - {/* Thumb indicator */} -
setPctThreshold(Number(e.target.value))} + className="w-full" />
-
- - Majority - - 50 && threshold <= 75 ? "text-neon-700" : "text-surface-400"}`} - > - Super Majority - - 75 ? "text-neon-700" : "text-surface-400"}`} - > - Unanimous - -
-
+ )} - {/* High-Level Approvers */} -
-

- Specific High-Level Approvers -

-

- Assign mandatory key stakeholders for transactions exceeding $5k. -

+ {/* Hybrid config */} + {ruleType === "hybrid" && ( +
+

+ Hybrid Rule +

+

+ Expenses above the threshold go straight to the override + approver. Below it, they follow the default chain. +

- {/* Selected Approvers */} -
- {selectedApprovers.map((approver) => ( -
-
- {approver.initials} +
+
+
+ + +
+
+ + setOverrideThreshold(e.target.value)} + className="w-full bg-white rounded-xl px-4 py-2.5 text-sm outline-none" + />
- {approver.name} -
- ))} -
- - {/* Add Input */} - +
- {/* Quick Suggestions */} -
-

- Quick Suggestions -

-
- {availableApprovers.map((person) => ( -
-
-

- {person.name} -

-

{person.role}

-
- - ))} + ))} +
+
+ + +
-
+ )}
- {/* Right Column - Sidebar */} + {/* Right column - preview */}
- {/* Workflow Preview */}

- Workflow Preview + Live Preview

- -
- {/* Latency Estimate */} -
-

- Estimated Processing -

-
-

- 2.4 day average{" "} - turnaround for approvals. -

-

- Based on current rule configuration -

-
-
+ {ruleType === "percentage" ? ( +
+

+ All {pctRole}s vote +

+

+ {pctThreshold}% approval required to pass +

+
+ ) : ( + + )} - {/* Smart Tip */} -
-
- -

- Smart Tip + {ruleType === "hybrid" && ( +

+ Expenses ≥ ₹{Number(overrideThreshold).toLocaleString()} skip + directly to {overrideRole}.

-
-

- Adding a 'Super Majority' rule for amounts over $50k increases - security but adds 12h to processing. -

+ )}
); -} +} \ No newline at end of file From 4c9bd5b1c3c140f5b518b5cf4eeeecdcd92db0ab Mon Sep 17 00:00:00 2001 From: Aditya Valsangkar Date: Wed, 5 Aug 2026 11:04:30 +0530 Subject: [PATCH 6/6] Updates --- frontend/.env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 4504e71..1784951 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,3 @@ -VITE_API_URL=http://localhost:3000/api -VITE_API_BASE_URL=http://localhost:3000/api -VITE_SOCKET_URL=http://localhost:3000 +VITE_API_URL=http://localhost:5000/api +VITE_API_BASE_URL=http://localhost:5000/api +VITE_SOCKET_URL=http://localhost:5000 \ No newline at end of file