const { useEffect, useMemo, useReducer, useState } = React;
const roles = {
admin: {
label: 'Admin',
permissions: ['bulkEdit', 'export', 'saveReports', 'viewDashboard']
},
editor: {
label: 'Editor',
permissions: ['bulkEdit', 'saveReports', 'viewDashboard']
},
viewer: {
label: 'Viewer',
permissions: ['viewDashboard']
}
};
const initialState = {
rows: [],
filters: { scenario: 'base', source: '', year: '' },
selectedIds: [],
savedReports: [],
csvPreview: [],
errors: {},
viewToken: ''
};
function reducer(state, action) {
switch (action.type) {
case 'setRows':
return { ...state, rows: action.rows };
case 'updateCell': {
const { id, field, value, error } = action;
const rows = state.rows.map((row) =>
row.id === id ? { ...row, [field]: value } : row
);
const errors = { ...state.errors };
if (error) {
errors[id] = { ...(errors[id] || {}), [field]: error };
} else if (errors[id]) {
const { [field]: _removed, ...rest } = errors[id];
errors[id] = rest;
if (!Object.keys(errors[id]).length) delete errors[id];
}
return { ...state, rows, errors };
}
case 'toggleSelect': {
const { id } = action;
const selectedIds = state.selectedIds.includes(id)
? state.selectedIds.filter((r) => r !== id)
: [...state.selectedIds, id];
return { ...state, selectedIds };
}
case 'setFilter': {
const filters = { ...state.filters, ...action.filters };
return { ...state, filters };
}
case 'bulkApply': {
const { patch } = action;
const rows = state.rows.map((row) =>
state.selectedIds.includes(row.id) ? { ...row, ...patch } : row
);
return { ...state, rows };
}
case 'setCsvPreview':
return { ...state, csvPreview: action.rows };
case 'commitCsvPreview': {
const nextId =
state.rows.reduce((max, r) => Math.max(max, Number(r.id) || 0), 0) + 1;
const mapped = state.csvPreview.map((row, idx) => ({
...row,
id: nextId + idx
}));
return { ...state, rows: [...mapped, ...state.rows], csvPreview: [] };
}
case 'loadReports':
return { ...state, savedReports: action.reports };
case 'saveReport':
return { ...state, savedReports: action.reports };
case 'applyReport':
return { ...state, filters: action.filters };
case 'setViewToken':
return { ...state, viewToken: action.token };
default:
return state;
}
}
function useData() {
const [state, dispatch] = useReducer(reducer, initialState);
const [role, setRole] = useState('admin');
useEffect(() => {
const saved = localStorage.getItem('fpa_reports');
if (saved) {
dispatch({ type: 'loadReports', reports: JSON.parse(saved) });
}
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const scenario = params.get('scenario');
const source = params.get('source');
const year = params.get('year');
if (scenario || source || year) {
dispatch({ type: 'setFilter', filters: { scenario: scenario || 'base', source: source || '', year: year || '' } });
}
}, []);
useEffect(() => {
fetch('/api/revenue')
.then((res) => res.json())
.then((data) => {
const rows = (data.entries || []).map((row, idx) => ({
...row,
id: row.id || idx + 1,
displayMonth: row.month,
amount: Number(row.amount || 0)
}));
if (!rows.length) {
rows.push(
{ id: 1, source: 'Enterprise', scenario: 'base', type: 'forecast', year: 2025, month: 1, amount: 120000 },
{ id: 2, source: 'SMB', scenario: 'upside', type: 'forecast', year: 2025, month: 2, amount: 98000 },
{ id: 3, source: 'Channel', scenario: 'downside', type: 'actual', year: 2024, month: 12, amount: 64000 }
);
}
dispatch({ type: 'setRows', rows });
})
.catch(() => {
dispatch({
type: 'setRows',
rows: [
{ id: 1, source: 'Enterprise', scenario: 'base', type: 'forecast', year: 2025, month: 1, amount: 120000 },
{ id: 2, source: 'SMB', scenario: 'upside', type: 'forecast', year: 2025, month: 2, amount: 98000 },
{ id: 3, source: 'Channel', scenario: 'downside', type: 'actual', year: 2024, month: 12, amount: 64000 }
]
});
});
}, []);
const filteredRows = useMemo(() => {
return state.rows.filter((row) => {
if (state.filters.scenario && row.scenario !== state.filters.scenario) return false;
if (state.filters.source && !row.source.toLowerCase().includes(state.filters.source.toLowerCase())) return false;
if (state.filters.year && String(row.year) !== String(state.filters.year)) return false;
return true;
});
}, [state.rows, state.filters]);
return { state, dispatch, filteredRows, role, setRole };
}
const Pill = ({ children }) => {children};
function FilterPanel({ filters, onChange, onShare }) {
return (
{filters.scenario || 'all'} scenario
{filters.year &&
{filters.year}}
);
}
function CsvPreview({ preview, onUpload }) {
const handleFile = (evt) => {
const file = evt.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
const [header, ...lines] = text.split(/\r?\n/).filter(Boolean);
const cols = header.split(',');
const rows = lines.map((line, idx) => {
const values = line.split(',');
return {
id: `csv-${idx + 1}`,
source: values[0] || 'Unknown',
scenario: (values[1] || 'base').toLowerCase(),
type: (values[2] || 'forecast').toLowerCase(),
year: Number(values[3]) || new Date().getFullYear(),
month: Number(values[4]) || 1,
amount: Number(values[5]) || 0
};
});
onUpload(rows);
};
reader.readAsText(file);
};
return (
{preview.length ? (
<>
{preview.length} rows staged. Validate inline before committing.
| Source |
Scenario |
Type |
Year |
Month |
Amount |
{preview.map((row) => (
| {row.source} |
{row.scenario} |
{row.type} |
{row.year} |
{row.month} |
{row.amount.toLocaleString()} |
))}
>
) : (
Drop a CSV with columns: source, scenario, type, year, month, amount.
)}
);
}
function BulkEditor({ onApply, disabled }) {
const [scenario, setScenario] = useState('');
const [type, setType] = useState('');
const [amountDelta, setAmountDelta] = useState('');
const handleApply = () => {
const patch = {};
if (scenario) patch.scenario = scenario;
if (type) patch.type = type;
if (amountDelta) patch.amountDelta = Number(amountDelta);
onApply(patch);
setScenario('');
setType('');
setAmountDelta('');
};
return (
Bulk edit
Mass-update selection
setAmountDelta(e.target.value)}
/>
);
}
function Grid({ rows, selected, errors, onSelect, onChange, disabled }) {
const handleChange = (row, field, value) => {
let error = '';
if (field === 'amount') {
if (Number.isNaN(Number(value))) {
error = 'Amount must be numeric';
} else if (Number(value) < 0) {
error = 'Amount cannot be negative';
}
}
onChange(row.id, field, field === 'amount' ? Number(value) : value, error);
};
return (
Live grid
Inline edits with validation
{rows.length} rows
|
Source |
Scenario |
Type |
Year |
Month |
Amount |
{rows.map((row) => {
const rowErrors = errors[row.id] || {};
return (
|
onSelect(row.id)}
/>
|
{row.source} |
|
|
handleChange(row, 'year', Number(e.target.value))}
/>
|
handleChange(row, 'month', Number(e.target.value))}
/>
|
handleChange(row, 'amount', e.target.value)}
/>
{rowErrors.amount && {rowErrors.amount} }
|
);
})}
);
}
function ExportPanel({ data, disabled }) {
const exportToExcel = () => {
const header = ['Source', 'Scenario', 'Type', 'Year', 'Month', 'Amount'];
const csv = [header.join(',')]
.concat(
data.map((row) =>
[row.source, row.scenario, row.type, row.year, row.month, row.amount].join(',')
)
)
.join('\n');
const blob = new Blob([csv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'fp&a-export.xlsx';
a.click();
URL.revokeObjectURL(url);
};
const exportToPdf = () => {
const popup = window.open('', 'print', 'height=600,width=800');
popup.document.write('FP&A Export');
popup.document.write('');
popup.document.write('');
popup.document.write('Revenue grid
');
popup.document.write('| Source | Scenario | Type | Year | Month | Amount |
');
data.forEach((row) => {
popup.document.write(
`| ${row.source} | ${row.scenario} | ${row.type} | ${row.year} | ${row.month} | ${row.amount} |
`
);
});
popup.document.write('
');
popup.document.write('');
popup.document.close();
popup.print();
};
return (
Exports honor filters and selections, enabling shareable offline packets.
);
}
function SavedReports({ reports, filters, onSave, onApply, disabled }) {
const [name, setName] = useState('');
const handleSave = () => {
if (!name) return;
const next = [...reports, { name, filters }];
localStorage.setItem('fpa_reports', JSON.stringify(next));
onSave(next);
setName('');
};
return (
Saved reports
Reusable dashboards
{reports.map((report, idx) => (
))}
);
}
function Dashboard({ data }) {
const monthly = useMemo(() => {
const buckets = {};
data.forEach((row) => {
const key = `${row.year}-${row.month}`;
buckets[key] = (buckets[key] || 0) + Number(row.amount || 0);
});
return Object.entries(buckets)
.map(([key, value]) => ({ key, value }))
.sort((a, b) => (a.key > b.key ? 1 : -1));
}, [data]);
const max = Math.max(...monthly.map((m) => m.value), 1);
return (
{monthly.map((m) => (
))}
);
}
function PermissionBanner({ role, onChange }) {
return (
Permissions
Role aware dashboards
Feature availability, bulk editing, exports, and saving reports respect the selected role. Use this to
validate guardrails before sharing links.
);
}
function App() {
const { state, dispatch, filteredRows, role, setRole } = useData();
const permissions = roles[role].permissions;
const applyFilter = (filters) => dispatch({ type: 'setFilter', filters });
const handleShare = () => {
const params = new URLSearchParams(state.filters).toString();
const url = `${window.location.origin}${window.location.pathname}?${params}`;
navigator.clipboard.writeText(url);
dispatch({ type: 'setViewToken', token: 'View link copied to clipboard' });
setTimeout(() => dispatch({ type: 'setViewToken', token: '' }), 2400);
};
const handleChange = (id, field, value, error) => {
dispatch({ type: 'updateCell', id, field, value, error });
};
const handleBulkApply = (patch) => {
if (!state.selectedIds.length) return;
const updates = {};
if (patch.scenario) updates.scenario = patch.scenario;
if (patch.type) updates.type = patch.type;
const rows = filteredRows
.filter((r) => state.selectedIds.includes(r.id))
.map((row) => ({ ...row, ...updates, amount: patch.amountDelta ? row.amount + patch.amountDelta : row.amount }));
const merged = state.rows.map((row) => rows.find((r) => r.id === row.id) || row);
dispatch({ type: 'setRows', rows: merged });
};
const handleCsvUpload = (rows, commit = false) => {
if (commit) {
dispatch({ type: 'commitCsvPreview' });
} else {
dispatch({ type: 'setCsvPreview', rows });
}
};
const canUse = (feature) => permissions.includes(feature);
return (
Aurelius FP&A ยท Componentized SPA
Revenue & Planning Workspace
React-powered single page experience with inline validation, CSV preview, bulk editing, and export-ready
reporting.
Stateful grids & charts
{state.viewToken &&
{state.viewToken}}
dispatch({ type: 'toggleSelect', id })}
onChange={handleChange}
disabled={!canUse('bulkEdit')}
/>
{canUse('bulkEdit') && (
)}
{canUse('export') && }
{canUse('saveReports') && (
dispatch({ type: 'saveReport', reports })}
onApply={(filters) => dispatch({ type: 'applyReport', filters })}
/>
)}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();