feat: comprehensive UI overhaul + budget allocation redesign
Audit & Quality: - RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties - A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons - Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens - Performance: useMemo on filters, loading="lazy" on 24 images - CSS: prefers-reduced-motion, removed dead animations Component Splits: - PostDetailPanel: 1332→623 lines + 4 sub-components - ArtefactDetailPanel: 972→590 lines + 1 sub-component Brand Identity — Rawaj (رواج): - New name, DM Sans font, deep teal palette (#0d9488) - Custom SVG logo, forest-tinted dark mode - All emails branded with app name in subject line Design Refinement: - Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats - Quieter: removed card lift, brand glow, gradient text, mesh backgrounds - CampaignDetail: prominent budget card, compact team avatars, Lucide icons - Consistent page titles via Header.jsx, standardized section headers - Finance page fully i18n'd (20+ hardcoded strings replaced) Budget Allocation Redesign: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Validation at all levels: main→campaign→track, expenses blocked if insufficient - Budget request workflow with CEO approval via public link - BudgetRequests table, CRUD routes, public approval page - Budget mutex for race condition prevention - Idempotent migration for existing campaign budgets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
# Budget Allocation Redesign — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income.
|
||||
|
||||
**Architecture:** BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions.
|
||||
|
||||
**Tech Stack:** Express.js (server), React (client), NocoDB (database), nodemailer (emails)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Server — Budget Model Fix + Validation
|
||||
|
||||
### Task 1: Add budget mutex utility
|
||||
|
||||
**Files:**
|
||||
- Create: `server/budget-mutex.js`
|
||||
|
||||
- [ ] **Step 1: Create the mutex module**
|
||||
|
||||
```javascript
|
||||
// server/budget-mutex.js
|
||||
let _lock = null;
|
||||
|
||||
async function acquireBudgetLock() {
|
||||
while (_lock) await _lock;
|
||||
let resolve;
|
||||
_lock = new Promise(r => { resolve = r; });
|
||||
return () => { _lock = null; resolve(); };
|
||||
}
|
||||
|
||||
module.exports = { acquireBudgetLock };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/budget-mutex.js
|
||||
git commit -m "feat: add budget mutex for race condition prevention"
|
||||
```
|
||||
|
||||
### Task 2: Add budget availability helper
|
||||
|
||||
**Files:**
|
||||
- Create: `server/budget-helpers.js`
|
||||
|
||||
This module computes `mainAvailable` and `campaignAvailable` from BudgetEntries — the single source of truth. Every route that modifies budget will call these.
|
||||
|
||||
- [ ] **Step 1: Create the helper module**
|
||||
|
||||
```javascript
|
||||
// server/budget-helpers.js
|
||||
const nocodb = require('./nocodb');
|
||||
const QUERY_LIMITS = { max: 10000 };
|
||||
|
||||
async function getMainAvailable() {
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
|
||||
const income = entries.filter(e => (e.type || 'income') === 'income');
|
||||
const expenses = entries.filter(e => e.type === 'expense');
|
||||
|
||||
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
|
||||
|
||||
return {
|
||||
totalReceived,
|
||||
totalExpenses,
|
||||
totalCampaignBudget,
|
||||
totalProjectBudget,
|
||||
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCampaignAvailable(campaignId) {
|
||||
const entries = await nocodb.list('BudgetEntries', {
|
||||
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
|
||||
limit: QUERY_LIMITS.max,
|
||||
});
|
||||
const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
|
||||
const tracks = await nocodb.list('CampaignTracks', {
|
||||
where: `(campaign_id,eq,${campaignId})`,
|
||||
limit: QUERY_LIMITS.max,
|
||||
});
|
||||
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
|
||||
|
||||
return { allocated, trackAllocated, available: allocated - trackAllocated };
|
||||
}
|
||||
|
||||
async function getCampaignAllocatedFromEntries(campaignId) {
|
||||
const entries = await nocodb.list('BudgetEntries', {
|
||||
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
|
||||
limit: QUERY_LIMITS.max,
|
||||
});
|
||||
return entries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
}
|
||||
|
||||
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/budget-helpers.js
|
||||
git commit -m "feat: add budget availability helpers (single source of truth)"
|
||||
```
|
||||
|
||||
### Task 3: Fix finance summary endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — the `GET /api/finance/summary` handler (~lines 2405-2488)
|
||||
|
||||
- [ ] **Step 1: Rewrite the finance summary to use BudgetEntries only**
|
||||
|
||||
Replace the entire handler body. Key changes:
|
||||
- `totalReceived` = sum of ALL income entries (same for all roles — remove the superadmin/manager fork)
|
||||
- `totalCampaignBudget` = sum of income entries with `campaign_id` set (not `Campaign.budget`)
|
||||
- `remaining` = mainAvailable (no track double-counting)
|
||||
- Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
|
||||
- Add `mainAvailable` to the response
|
||||
|
||||
The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
```bash
|
||||
cd client && npx vite build --logLevel error
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "fix: finance summary uses BudgetEntries as single source of truth"
|
||||
```
|
||||
|
||||
### Task 4: Add budget validation to campaign creation
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — `POST /api/campaigns` (~line 2097)
|
||||
|
||||
- [ ] **Step 1: Add validation + auto-create BudgetEntry**
|
||||
|
||||
In the campaign creation handler, after creating the campaign:
|
||||
1. Import `{ acquireBudgetLock }` from `./budget-mutex`
|
||||
2. Import `{ getMainAvailable }` from `./budget-helpers`
|
||||
3. If `budget > 0`:
|
||||
- Acquire lock
|
||||
- Check `mainAvailable >= budget`
|
||||
- If insufficient: delete the just-created campaign, release lock, return 400
|
||||
- If OK: create BudgetEntry `{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) }`
|
||||
- Release lock
|
||||
|
||||
- [ ] **Step 2: Add validation to campaign PATCH for budget changes**
|
||||
|
||||
Modify: `server/server.js` — `PATCH /api/campaigns/:id`
|
||||
|
||||
If `budget` field is being updated:
|
||||
1. Get current allocated = sum of income BudgetEntries for this campaign
|
||||
2. If increasing: check `mainAvailable >= (newBudget - currentAllocated)`
|
||||
3. If decreasing: check `newBudget >= sum(tracks.budget_allocated)` for this campaign
|
||||
4. Update (or create) the BudgetEntry to match new budget amount
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry"
|
||||
```
|
||||
|
||||
### Task 5: Add budget validation to track creation/edit
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — `POST /api/campaigns/:id/tracks` (~line 2504) and `PATCH /api/campaigns/:id/tracks/:trackId`
|
||||
|
||||
- [ ] **Step 1: Add campaignAvailable check to track POST**
|
||||
|
||||
Before creating the track:
|
||||
1. Import `{ getCampaignAvailable }` from `./budget-helpers`
|
||||
2. If `budget_allocated > 0`: check `campaignAvailable >= budget_allocated`
|
||||
3. If insufficient: return 400 `{ error: 'Insufficient campaign budget', available: campaignAvailable }`
|
||||
|
||||
- [ ] **Step 2: Add same check to track PATCH**
|
||||
|
||||
If `budget_allocated` is being updated:
|
||||
1. Get current track's `budget_allocated`
|
||||
2. Delta = newAmount - currentAmount
|
||||
3. If delta > 0: check `campaignAvailable >= delta`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: validate track budget against campaign available"
|
||||
```
|
||||
|
||||
### Task 6: Add budget validation to expense creation
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — `POST /api/budget` (~line 2343)
|
||||
|
||||
- [ ] **Step 1: Add mainAvailable check for expenses**
|
||||
|
||||
In the budget entry creation handler:
|
||||
1. Validate `amount > 0`
|
||||
2. If `type === 'expense'`: acquire lock, check `mainAvailable >= amount`, release
|
||||
3. If insufficient: return 400
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: validate expense entries against available budget"
|
||||
```
|
||||
|
||||
### Task 7: Handle campaign/project deletion — release budget
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — `DELETE /api/campaigns/:id` (~line 2174) and `DELETE /api/projects/:id`
|
||||
|
||||
- [ ] **Step 1: Null out BudgetEntry FKs on campaign delete**
|
||||
|
||||
In the campaign delete handler, before deleting the campaign:
|
||||
```javascript
|
||||
// Release budget entries back to main
|
||||
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Same for project delete**
|
||||
|
||||
```javascript
|
||||
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max });
|
||||
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: release budget on campaign/project deletion"
|
||||
```
|
||||
|
||||
### Task 8: Migration — create BudgetEntries for existing campaigns
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — add migration in `startServer()` function
|
||||
|
||||
- [ ] **Step 1: Add migration after ensureTextColumns**
|
||||
|
||||
```javascript
|
||||
// Migrate Campaign.budget → BudgetEntries (one-time, idempotent)
|
||||
async function migrateCampaignBudgets() {
|
||||
const campaigns = await nocodb.list('Campaigns', { limit: 10000 });
|
||||
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
||||
|
||||
for (const c of campaigns) {
|
||||
if (!c.budget || c.budget <= 0) continue;
|
||||
const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income');
|
||||
if (existing) continue;
|
||||
|
||||
await nocodb.create('BudgetEntries', {
|
||||
label: `Campaign allocation: ${c.name}`,
|
||||
amount: c.budget,
|
||||
type: 'income',
|
||||
campaign_id: c.Id,
|
||||
source: 'Migrated from Campaign.budget',
|
||||
date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
category: 'marketing',
|
||||
});
|
||||
console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `await migrateCampaignBudgets()` in `startServer()` after table creation.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Server — Budget Request Workflow + CEO Approval
|
||||
|
||||
### Task 9: Add BudgetRequests table schema + CEO email setting
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js` — REQUIRED_TABLES, TEXT_COLUMNS, appSettings
|
||||
|
||||
- [ ] **Step 1: Add BudgetRequests to REQUIRED_TABLES**
|
||||
|
||||
```javascript
|
||||
BudgetRequests: [
|
||||
{ title: 'amount', uidt: 'Decimal' },
|
||||
{ title: 'justification', uidt: 'LongText' },
|
||||
{ title: 'status', uidt: 'SingleLineText' },
|
||||
{ title: 'requested_by_user_id', uidt: 'Number' },
|
||||
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||
{ title: 'response_note', uidt: 'LongText' },
|
||||
{ title: 'earmarked_campaign_id', uidt: 'Number' },
|
||||
{ title: 'earmarked_project_id', uidt: 'Number' },
|
||||
{ title: 'created_budget_entry_id', uidt: 'Number' },
|
||||
],
|
||||
```
|
||||
|
||||
Add to TEXT_COLUMNS:
|
||||
```javascript
|
||||
BudgetRequests: [
|
||||
{ name: 'token_expires_at', uidt: 'SingleLineText' },
|
||||
{ name: 'resolved_at', uidt: 'SingleLineText' },
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add ceoEmail to appSettings default**
|
||||
|
||||
In the `defaultSettings` object (wherever `uploadMaxSizeMB` is initialized), add:
|
||||
```javascript
|
||||
ceoEmail: ''
|
||||
```
|
||||
|
||||
In `PATCH /api/settings/app`, add handling for `ceoEmail`:
|
||||
```javascript
|
||||
if (ceoEmail !== undefined) {
|
||||
appSettings.ceoEmail = String(ceoEmail).trim();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: add BudgetRequests table schema + ceoEmail setting"
|
||||
```
|
||||
|
||||
### Task 10: Add budget request CRUD routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js`
|
||||
|
||||
- [ ] **Step 1: Add GET /api/budget-requests**
|
||||
|
||||
```javascript
|
||||
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
try {
|
||||
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
|
||||
// Enrich with requester name, campaign/project names
|
||||
res.json(requests);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load budget requests' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add POST /api/budget-requests**
|
||||
|
||||
```javascript
|
||||
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
|
||||
if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' });
|
||||
if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' });
|
||||
|
||||
const ceoEmail = appSettings.ceoEmail;
|
||||
if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' });
|
||||
|
||||
const token = require('crypto').randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
try {
|
||||
const created = await nocodb.create('BudgetRequests', {
|
||||
amount,
|
||||
justification: justification.trim(),
|
||||
status: 'pending',
|
||||
requested_by_user_id: req.session.userId,
|
||||
approval_token: token,
|
||||
token_expires_at: expiresAt,
|
||||
earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null,
|
||||
earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null,
|
||||
});
|
||||
|
||||
// Send email to CEO
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
|
||||
const approvalUrl = `${appUrl}/approve-budget/${token}`;
|
||||
const requesterName = req.session.userName || 'Team member';
|
||||
// Use notifications.js pattern for email
|
||||
const { sendMail } = require('./mail');
|
||||
await sendMail({
|
||||
to: ceoEmail,
|
||||
subject: `Rawaj — Budget Request: ${amount}`,
|
||||
html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }),
|
||||
text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`,
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
console.error('Budget request error:', err);
|
||||
res.status(500).json({ error: 'Failed to create budget request' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Add `renderBudgetRequestEmail` helper near the route (uses the same branded template pattern as notifications.js).
|
||||
|
||||
- [ ] **Step 3: Add PATCH /api/budget-requests/:id/cancel**
|
||||
|
||||
```javascript
|
||||
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
try {
|
||||
const request = await nocodb.get('BudgetRequests', req.params.id);
|
||||
if (!request) return res.status(404).json({ error: 'Not found' });
|
||||
if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' });
|
||||
await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to cancel request' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js
|
||||
git commit -m "feat: add budget request CRUD routes"
|
||||
```
|
||||
|
||||
### Task 11: Add public approval endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/server.js`
|
||||
|
||||
- [ ] **Step 1: Add GET /api/budget-approval/:token (public, no auth)**
|
||||
|
||||
Returns request details for the approval page. Validates token exists and hasn't expired.
|
||||
|
||||
- [ ] **Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)**
|
||||
|
||||
Body: `{ action: 'approve' | 'reject', note?: string }`
|
||||
|
||||
On approve:
|
||||
1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
|
||||
2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
|
||||
3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
|
||||
4. Send notification email to requester (superadmin)
|
||||
|
||||
On reject:
|
||||
1. Update request: status='rejected', response_note=note, resolved_at=now
|
||||
2. Send notification email to requester
|
||||
|
||||
- [ ] **Step 3: Add notification helpers for budget approval/rejection**
|
||||
|
||||
Add to `server/notifications.js`:
|
||||
- `notifyBudgetApproved({ request, entryId })` — emails the requesting superadmin
|
||||
- `notifyBudgetRejected({ request, note })` — emails the requesting superadmin
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/server.js server/notifications.js
|
||||
git commit -m "feat: add public budget approval endpoints + notifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Client — Finance Page + Budget Request UI
|
||||
|
||||
### Task 12: Update Finance page to show budget requests
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Finance.jsx`
|
||||
|
||||
- [ ] **Step 1: Add budget requests fetch and state**
|
||||
|
||||
Add `budgetRequests` state, fetch from `GET /api/budget-requests` alongside the finance summary.
|
||||
|
||||
- [ ] **Step 2: Add "Request Budget" button in header (superadmin only)**
|
||||
|
||||
Next to the page title, show a teal button that opens a modal.
|
||||
|
||||
- [ ] **Step 3: Add budget request modal**
|
||||
|
||||
Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls `POST /api/budget-requests`.
|
||||
|
||||
- [ ] **Step 4: Add budget requests section**
|
||||
|
||||
Below the existing finance sections, add a "Budget Requests" list:
|
||||
- Pending: amber badge, shows cancel button
|
||||
- Approved: green badge, shows linked entry amount
|
||||
- Rejected: red badge, shows CEO's note
|
||||
- Cancelled: gray badge
|
||||
|
||||
If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval"
|
||||
|
||||
- [ ] **Step 5: Add i18n keys for budget requests**
|
||||
|
||||
Add to both `en.json` and `ar.json`:
|
||||
- `finance.requestBudget`, `finance.budgetRequests`, `finance.pendingApproval`, `finance.justification`, `finance.earmarkFor`, `finance.submitRequest`, `finance.cancelRequest`, `finance.approved`, `finance.rejected`, `finance.cancelled`, `finance.pending`, `finance.ceoNote`, `finance.requestPending`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||
git commit -m "feat: budget requests UI on Finance page"
|
||||
```
|
||||
|
||||
### Task 13: Update Settings page — CEO email field
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Settings.jsx`
|
||||
|
||||
- [ ] **Step 1: Add CEO email field in settings (superadmin only)**
|
||||
|
||||
In the settings form, add a section "Budget Approval":
|
||||
- Label: "CEO / Budget Approver Email"
|
||||
- Input: email type, bound to `appSettings.ceoEmail`
|
||||
- Save alongside existing settings via `PATCH /api/settings/app`
|
||||
|
||||
- [ ] **Step 2: Add i18n keys**
|
||||
|
||||
`settings.ceoEmail`, `settings.ceoEmailHint`, `settings.budgetApproval`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||
git commit -m "feat: CEO email setting for budget approval"
|
||||
```
|
||||
|
||||
### Task 14: Update Dashboard BudgetSummary
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Dashboard.jsx`
|
||||
|
||||
- [ ] **Step 1: Update BudgetSummary to use new response shape**
|
||||
|
||||
The finance summary response now has `mainAvailable` instead of computing `remaining` from the old formula. Update the component to use the new field. The `spent` field from tracks is no longer subtracted from main — it lives within campaign allocations.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Dashboard.jsx
|
||||
git commit -m "fix: dashboard budget uses new single-source response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI
|
||||
|
||||
### Task 15: Create public budget approval page
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/PublicBudgetApproval.jsx`
|
||||
- Modify: `client/src/App.jsx` — add route `/approve-budget/:token`
|
||||
|
||||
- [ ] **Step 1: Create the page component**
|
||||
|
||||
Follow the same pattern as `PublicReview.jsx`:
|
||||
1. Fetch request via `GET /api/budget-approval/:token`
|
||||
2. Show: amount, requester name, justification, earmarked for (if set)
|
||||
3. Approve / Reject buttons + optional note textarea
|
||||
4. Submit via `POST /api/budget-approval/:token/respond`
|
||||
5. States: loading, active, success (with approved/rejected message), already-handled, expired, error
|
||||
|
||||
Use the teal brand color for the approve button, red for reject.
|
||||
|
||||
- [ ] **Step 2: Add route in App.jsx**
|
||||
|
||||
```jsx
|
||||
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
|
||||
```
|
||||
|
||||
Add this alongside other public routes (before the auth-protected layout).
|
||||
|
||||
- [ ] **Step 3: Add i18n keys**
|
||||
|
||||
`budgetApproval.title`, `budgetApproval.amount`, `budgetApproval.requestedBy`, `budgetApproval.justification`, `budgetApproval.earmarkedFor`, `budgetApproval.approve`, `budgetApproval.reject`, `budgetApproval.addNote`, `budgetApproval.approved`, `budgetApproval.rejected`, `budgetApproval.expired`, `budgetApproval.alreadyHandled`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json
|
||||
git commit -m "feat: public budget approval page"
|
||||
```
|
||||
|
||||
### Task 16: Add budget validation feedback to campaign creation UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Campaigns.jsx` (or wherever campaign creation modal lives)
|
||||
|
||||
- [ ] **Step 1: Show available budget near the budget input**
|
||||
|
||||
When user enters a budget amount for a new campaign, fetch `mainAvailable` from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button.
|
||||
|
||||
- [ ] **Step 2: Handle 400 error from server**
|
||||
|
||||
If campaign creation returns 400 with `{ error: 'Insufficient budget', available: X }`, show a toast or inline error with the available amount and a suggestion to request more.
|
||||
|
||||
- [ ] **Step 3: Same for track creation in CampaignDetail**
|
||||
|
||||
When adding a track, show campaign available budget. Handle 400 insufficient errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx
|
||||
git commit -m "feat: budget validation UI for campaigns and tracks"
|
||||
```
|
||||
|
||||
### Task 17: Final verification
|
||||
|
||||
- [ ] **Step 1: Build check**
|
||||
|
||||
```bash
|
||||
cd client && npx vite build --logLevel error
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manual test checklist**
|
||||
|
||||
1. Create income via budget request → CEO approves → funds appear
|
||||
2. Create campaign with budget > available → blocked
|
||||
3. Create campaign with budget ≤ available → succeeds, BudgetEntry created
|
||||
4. Create track exceeding campaign budget → blocked
|
||||
5. Delete campaign → funds return to main
|
||||
6. Create expense > available → blocked
|
||||
7. Reduce campaign budget below track allocations → blocked
|
||||
8. Finance summary shows correct numbers (same for superadmin and manager)
|
||||
|
||||
- [ ] **Step 3: Commit any final fixes**
|
||||
@@ -0,0 +1,245 @@
|
||||
# Budget Allocation Redesign — Single Source of Truth + CEO Approval Workflow
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
The current budget system has two parallel sources of truth:
|
||||
- `Campaign.budget` field (set directly on campaigns)
|
||||
- `BudgetEntries` table (income/expense records linked to campaigns/projects)
|
||||
|
||||
These don't sync. The finance summary uses `Campaign.budget` for `totalCampaignBudget` but `BudgetEntries` for `totalReceived` (superadmin only). Managers see a completely different `totalReceived` calculation. There's no validation preventing over-allocation, and no approval workflow for incoming funds.
|
||||
|
||||
## Design
|
||||
|
||||
### Budget Hierarchy
|
||||
|
||||
```
|
||||
Main Budget (sum of approved income BudgetEntries)
|
||||
│
|
||||
├─ Expenses (BudgetEntries with type='expense', deducted from main)
|
||||
├─ Campaign allocations (income BudgetEntries with campaign_id set)
|
||||
├─ Project allocations (income BudgetEntries with project_id set)
|
||||
│
|
||||
└─ Available = Main Budget - expenses - campaign allocations - project allocations
|
||||
│
|
||||
Campaign "Summer Sale" ($10K allocated)
|
||||
├─ Track "Facebook Ads" ($3K from campaign)
|
||||
├─ Track "Google Ads" ($5K from campaign)
|
||||
└─ Campaign Available = $2K
|
||||
```
|
||||
|
||||
Campaign allocation entries are a **subset** of income entries — they are income entries that happen to have a `campaign_id` set. An earmarked CEO-approved income entry counts as both `totalReceived` and `totalCampaignBudget` (which is correct — the money enters the system AND is allocated).
|
||||
|
||||
### Single Source of Truth
|
||||
|
||||
**BudgetEntries is the only source.** `Campaign.budget` field is deprecated — kept in schema but ignored in all calculations.
|
||||
|
||||
All calculations:
|
||||
- `totalReceived` = sum of all income BudgetEntries (same for all roles)
|
||||
- `totalExpenses` = sum of all expense BudgetEntries
|
||||
- `totalCampaignBudget` = sum of income BudgetEntries where `campaign_id` is set
|
||||
- `totalProjectBudget` = sum of income BudgetEntries where `project_id` is set
|
||||
- `mainAvailable` = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
|
||||
- `campaignAvailable(id)` = campaign's allocated budget - sum of its tracks' `budget_allocated`
|
||||
- `remaining` = mainAvailable (same thing — no more double-counting tracks)
|
||||
|
||||
### Validation Rules
|
||||
|
||||
| Action | Guard | Error message |
|
||||
|--------|-------|---------------|
|
||||
| All amounts | amount > 0 | "Amount must be positive" |
|
||||
| Create expense entry | mainAvailable >= amount | "Insufficient budget. Available: $X" |
|
||||
| Set campaign budget (at creation or edit) | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
|
||||
| Decrease campaign budget | newBudget >= sum(tracks.budget_allocated) | "Cannot reduce below track allocations ($X assigned to tracks)" |
|
||||
| Set project budget | mainAvailable >= amount (or >= increase delta) | "Insufficient budget. Available: $X. Request more funds." |
|
||||
| Set track budget_allocated | campaignAvailable >= amount (or >= increase delta) | "Insufficient campaign budget. Available: $X of $Y allocated" |
|
||||
| Create income entry | Must go through budget request workflow (superadmin only) | N/A — handled by request workflow |
|
||||
|
||||
**Race condition mitigation:** Budget-modifying operations (campaign/project/expense creation, budget changes) acquire an in-memory mutex before reading availability and releasing after the write. Single-server app — no distributed lock needed.
|
||||
|
||||
### Campaign/Project Deletion
|
||||
|
||||
When a campaign or project is deleted:
|
||||
- All linked BudgetEntries have their `campaign_id` / `project_id` set to null
|
||||
- This returns the allocated funds to the main available balance
|
||||
- Tracks under the campaign are already deleted by the existing cascade logic
|
||||
|
||||
### Budget Request Workflow (CEO Approval)
|
||||
|
||||
**Who can request:** Superadmin only.
|
||||
**Who approves:** CEO — external email address configured in Settings page.
|
||||
|
||||
#### Flow
|
||||
|
||||
```
|
||||
1. Superadmin opens Finance page → clicks "Request Budget"
|
||||
2. Fills form: amount, justification note
|
||||
Optional: earmarked for specific campaign or project
|
||||
3. System creates BudgetRequest (status: pending)
|
||||
4. System generates approval token + public URL (expires in 7 days)
|
||||
5. System emails CEO: amount, requester name, justification, approve/reject links
|
||||
(no internal budget details exposed)
|
||||
|
||||
6a. CEO clicks Approve:
|
||||
→ BudgetRequest.status = 'approved', resolved_at = now
|
||||
→ Auto-creates income BudgetEntry (amount, source: "CEO Approved — {justification}")
|
||||
→ If earmarked: BudgetEntry gets campaign_id or project_id
|
||||
→ Email notification to superadmin: "Your budget request for $X has been approved"
|
||||
|
||||
6b. CEO clicks Reject:
|
||||
→ BudgetRequest.status = 'rejected', resolved_at = now
|
||||
→ CEO can add a response note
|
||||
→ Email notification to superadmin: "Your budget request for $X has been rejected"
|
||||
```
|
||||
|
||||
Idempotent: if CEO clicks approve/reject twice, return 200 with existing result — no duplicate entries.
|
||||
|
||||
#### New Table: BudgetRequests
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| amount | Decimal | Requested amount (must be > 0) |
|
||||
| justification | LongText | Why the money is needed |
|
||||
| status | SingleLineText | pending / approved / rejected / cancelled |
|
||||
| requested_by_user_id | Number | FK to Users |
|
||||
| approval_token | SingleLineText | UUID for public approval URL |
|
||||
| token_expires_at | DateTime | Token expiry (7 days from creation) |
|
||||
| response_note | LongText | CEO's note on approval/rejection |
|
||||
| resolved_at | DateTime | When CEO acted (null while pending) |
|
||||
| earmarked_campaign_id | Number | Optional FK — intended campaign |
|
||||
| earmarked_project_id | Number | Optional FK — intended project |
|
||||
| created_budget_entry_id | Number | FK to the auto-created BudgetEntry (set on approval) |
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | /api/budget-requests | Superadmin | List all requests (sorted by CreatedAt desc) |
|
||||
| POST | /api/budget-requests | Superadmin | Create new request, sends email to CEO |
|
||||
| PATCH | /api/budget-requests/:id/cancel | Superadmin | Cancel a pending request |
|
||||
| GET | /api/budget-approval/:token | Public | Get request details for approval page |
|
||||
| POST | /api/budget-approval/:token/respond | Public | Approve or reject (body: `{ action: 'approve'|'reject', note?: string }`) |
|
||||
|
||||
#### Public Approval Page: `/approve-budget/:token`
|
||||
|
||||
Minimal page showing:
|
||||
- Requested amount
|
||||
- Requester name
|
||||
- Justification note
|
||||
- Earmarked for (campaign/project name, if set)
|
||||
- Two buttons: Approve / Reject
|
||||
- Optional text field for response note
|
||||
- States: loading, active, success, already-handled, expired
|
||||
|
||||
Same pattern as existing public review pages (`PublicReview.jsx`, `PublicPostReview.jsx`).
|
||||
|
||||
Token validation: check `token_expires_at > now` and `status === 'pending'`. Expired tokens show "This request has expired" with no action buttons.
|
||||
|
||||
### Settings: CEO Email
|
||||
|
||||
Add to Settings page (superadmin only):
|
||||
- Field: "CEO / Budget Approver Email"
|
||||
- Stored in `AppSettings` table (key-value: `{ key: 'ceo_email', value: 'ceo@company.com' }`)
|
||||
|
||||
**AppSettings table schema:**
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| key | SingleLineText | Setting key (unique) |
|
||||
| value | LongText | Setting value |
|
||||
|
||||
Add to `REQUIRED_TABLES`. Read via `GET /api/settings/:key` (superadmin), write via `PATCH /api/settings/:key` (superadmin).
|
||||
|
||||
### Finance Page Changes
|
||||
|
||||
Add a "Budget Requests" section to the Finance page:
|
||||
- Shows all requests with status badge (pending/approved/rejected/cancelled)
|
||||
- Pending requests show a subtle banner at top: "1 budget request pending CEO approval"
|
||||
- Each row: amount, justification (truncated), status, date, earmarked for, resolved_at
|
||||
- Superadmin sees "Request Budget" button in the page header
|
||||
|
||||
### Campaign Creation Change
|
||||
|
||||
When creating/editing a campaign with a budget:
|
||||
1. Acquire budget mutex
|
||||
2. Server calculates `mainAvailable`
|
||||
3. If `budget > mainAvailable`: return 400 with `{ error: 'Insufficient budget', available: mainAvailable }`
|
||||
4. If OK: create campaign, then auto-create BudgetEntry (type=income, campaign_id=new campaign ID, amount=budget)
|
||||
5. `Campaign.budget` field is still written for backward compat but NOT used in calculations
|
||||
6. Release mutex
|
||||
|
||||
When increasing a campaign's budget:
|
||||
- Delta = newBudget - currentAllocated (where currentAllocated = sum of income BudgetEntries with this campaign_id)
|
||||
- Acquire mutex, check `mainAvailable >= delta`, update BudgetEntry amount, release
|
||||
|
||||
When decreasing a campaign's budget:
|
||||
- Check `newBudget >= sum(tracks.budget_allocated for this campaign)`
|
||||
- If not: return 400 "Cannot reduce below track allocations"
|
||||
- Update BudgetEntry amount (freed funds return to main automatically)
|
||||
|
||||
### Track Creation/Edit Change
|
||||
|
||||
When creating/editing a track with `budget_allocated`:
|
||||
1. Calculate `campaignAllocated` = sum of income BudgetEntries with this campaign_id
|
||||
2. Calculate `tracksTotalAllocated` = sum of all tracks' `budget_allocated` for this campaign (excluding current track if editing)
|
||||
3. `campaignAvailable = campaignAllocated - tracksTotalAllocated`
|
||||
4. If `budget_allocated > campaignAvailable`: return 400
|
||||
5. If OK: save normally
|
||||
|
||||
### Finance Summary Endpoint Fix
|
||||
|
||||
`GET /api/finance/summary` — rewrite calculation:
|
||||
|
||||
```javascript
|
||||
// Single source of truth — BudgetEntries only
|
||||
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
|
||||
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
|
||||
|
||||
const totalReceived = incomeEntries.reduce((s, e) => s + (e.amount || 0), 0); // SAME for all roles
|
||||
const totalExpenses = expenseEntries.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
|
||||
const totalCampaignBudget = incomeEntries
|
||||
.filter(e => e.campaign_id)
|
||||
.reduce((s, e) => s + (e.amount || 0), 0); // FROM ENTRIES, not Campaign.budget
|
||||
|
||||
const totalProjectBudget = incomeEntries
|
||||
.filter(e => e.project_id)
|
||||
.reduce((s, e) => s + (e.amount || 0), 0);
|
||||
|
||||
const mainAvailable = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
|
||||
|
||||
// Track spending stays within campaign allocation — not subtracted from main
|
||||
const remaining = mainAvailable; // Simple. No double-counting.
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
Existing data:
|
||||
1. For each campaign with `budget > 0` that has NO corresponding income BudgetEntry with that campaign_id: auto-create an income BudgetEntry linked to that campaign
|
||||
2. Skip campaigns with `budget = 0` or `budget = null`
|
||||
3. Log migrations to console for audit
|
||||
4. Run once on server startup (idempotent — skip if matching entry already exists)
|
||||
|
||||
### Email Templates
|
||||
|
||||
Budget request email to CEO:
|
||||
- Subject: `Rawaj — Budget Request: $X`
|
||||
- Header: Rawaj branded (dark forest `#0a1f1c`)
|
||||
- Body: "{requester} is requesting $X. Justification: {note}"
|
||||
- CTA: "Review Request" button → public approval page
|
||||
- No internal budget details
|
||||
|
||||
Approval/rejection notification to superadmin:
|
||||
- Subject: `Rawaj — Budget Request Approved/Rejected: $X`
|
||||
- Body: result + CEO's response note if any
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-currency support
|
||||
- Budget periods/fiscal years
|
||||
- Partial approval (CEO can't approve a different amount)
|
||||
- Delegation (CEO can't forward approval to someone else)
|
||||
- Audit log (beyond the BudgetRequests table itself)
|
||||
- Currency precision (uses NocoDB Decimal as-is)
|
||||
Reference in New Issue
Block a user