Skip to content

Commit

Permalink
Merge pull request #78 from ucdavis/swe/UngroupedExpenses
Browse files Browse the repository at this point in the history
Add tab for displaying ungrouped expenses
  • Loading branch information
sprucely authored Jan 6, 2023
2 parents ea67ce8 + 8273b1e commit 95f2ee6
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 6 deletions.
6 changes: 3 additions & 3 deletions src/AD419/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/AD419/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"react-scripts": "^3.4.1",
"reactstrap": "^8.4.1",
"rimraf": "^3.0.2",
"sass": "^1.56.1"
"sass": "^1.56.2"
},
"devDependencies": {
"@types/react-bootstrap-typeahead": "^3.4.6",
Expand Down
4 changes: 3 additions & 1 deletion src/AD419/ClientApp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { Layout } from './components/Layout';

import AssociationContainer from './components/associations/AssociationContainer';
import SummaryContainer from './components/summary/SummaryContainer';
import ExpensesContainer from './components/expenses/ExpensesContainer';
import Access from './Access';

import './sass/custom.scss'
import './sass/custom.scss';

export default class App extends Component {
static displayName = App.name;
Expand All @@ -16,6 +17,7 @@ export default class App extends Component {
<Layout>
<Route exact path='/' component={AssociationContainer} />
<Route path='/summary' component={SummaryContainer} />
<Route path='/expenses' component={ExpensesContainer} />
<Route path='/access' component={Access} />
</Layout>
);
Expand Down
5 changes: 5 additions & 0 deletions src/AD419/ClientApp/src/components/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const NavMenu = (): JSX.Element => {
Associations
</NavLink>
</NavItem>
<NavItem>
<NavLink className='nav-link text-dark' to='/expenses'>
Expenses
</NavLink>
</NavItem>
<NavItem>
<NavLink className='nav-link text-dark' to='/summary'>
Summary
Expand Down
105 changes: 105 additions & 0 deletions src/AD419/ClientApp/src/components/expenses/ExpenseTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { UngroupedExpense } from '../../models';
import NumberDisplay from '../NumberDisplay';
import { TableFilter } from '../Filter';
import { groupBy } from '../../utilities';

interface Props {
expenses: UngroupedExpense[];
loading: boolean;
}

export default function ExpenseTable(props: Props): JSX.Element {
// ability to filter the unselected projects by project or PI
const [filter, setFilter] = useState<string>();

const data = useMemo(() => {
if (filter) {
const lowerFilter = filter.toLowerCase();

return props.expenses.filter((exp) => {
// TODO: what do we want to filter on?
// For now, filter on everything
for (const value of Object.values(exp)) {
if (value && String(value).toLowerCase().includes(lowerFilter)) {
return true;
}
}

// nothing matched, don't include this expense
return false;
});
} else {
return props.expenses;
}
}, [filter, props.expenses]);

if (props.loading) {
return (
<div className='loading-expander text-center'>
<b>Loading...</b>
</div>
);
}

return (
<>
<div className='card-body card-bg'>
<TableFilter filter={filter} setFilter={setFilter}></TableFilter>
</div>
<table className='table'>
<thead>
<tr>
<th>SFN</th>
<th>OrgR</th>
<th>Project</th>
<th>PI</th>
<th>Spent</th>
</tr>
</thead>
<tbody>
{data.map((expense, i) => (
<tr key={expense.expenseId + expense.orgR + i}>
<td>{expense.sfn}</td>
<td>{expense.orgR}</td>
<td>{expense.project}</td>
<td>{expense.pi}</td>
<td>
<NumberDisplay
value={expense.expenses}
precision={2}
type='currency'
></NumberDisplay>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={4}></th>
<th>SFN Subtotal</th>
</tr>
{groupBy(data, (d) => d.sfn).map(([sfn, expenses]) => (
<tr key={sfn}>
<td colSpan={4}>{sfn}</td>
<td>
<NumberDisplay
value={expenses.reduce((sum, exp) => sum + exp.expenses, 0)}
precision={2}
type='currency'
></NumberDisplay>
</td>
</tr>
))}
</tfoot>
</table>
{data.length === 0 && (
<div>
<p className='text-center mt-2'>
No expenses found for given parameters.
</p>
</div>
)}
</>
);
}
141 changes: 141 additions & 0 deletions src/AD419/ClientApp/src/components/expenses/ExpensesContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useEffect, useState, useCallback } from 'react';

import { useHistory } from 'react-router-dom';

import ExpenseTable from './ExpenseTable';

import { Organization, SFNRecord, UngroupedExpense } from '../../models';

export default function ExpensesContainer(): JSX.Element {
const [orgs, setOrgs] = useState<Organization[]>([]);
const [sfns, setSFNs] = useState<SFNRecord[]>([]);
const [selectedOrg, setSelectedOrg] = useState<Organization>();
const [selectedSFN, setSelectedSFN] = useState<SFNRecord>();

const [expenses, setExpenses] = useState<UngroupedExpense[]>([]);

const [expensesLoading, setExpensesLoading] = useState<boolean>(true);

const history = useHistory();

// fire only once to grab initial orgs
useEffect(() => {
const getDepartments = async (): Promise<void> => {
const result = await fetch('/Organization');
const data = await result.json();

// need to allow any because the return type is odd
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orgs: Organization[] = data.map((d: any) => {
return {
code: d.orgR,
name: d.name,
};
});

if (data && data.length > 0) {
setOrgs(orgs);
setSelectedOrg(orgs[0]);
} else {
// no department access
history.push('/access');
}
};

getDepartments(); // go grab the depts
}, [history]);

useEffect(() => {
const getSFNs = async (): Promise<void> => {
const result = await fetch('/Expense/SFNs');
const data = await result.json();
const sfns = [{ sfn: 'All', description: '-- All SFNs --' }, ...data];
if (data && data.length > 0) {
setSFNs(sfns);
setSelectedSFN(sfns[0]);
}
};
getSFNs();
}, []);

const getExpensesCallback = useCallback(async (): Promise<void> => {
setExpensesLoading(true);
const result = await fetch(
`/Expense/Ungrouped?org=${selectedOrg?.code}&sfn=${selectedSFN?.sfn}`
);
const expenses = await result.json();

setExpenses(expenses);
setExpensesLoading(false);
}, [selectedOrg, selectedSFN]);

// requery whenever our grouping or org changes
useEffect(() => {
if (selectedOrg) {
getExpensesCallback();
}
}, [selectedOrg, getExpensesCallback]);

const orgSelected = (e: React.ChangeEvent<HTMLSelectElement>): void => {
const val = e.target.value;

const org = orgs.find((o) => o.code === val);
setSelectedOrg(org);
};

const sfnSelected = (e: React.ChangeEvent<HTMLSelectElement>): void => {
const val = e.target.value;

const sfn = sfns.find((s) => s.sfn === val);
setSelectedSFN(sfn);
};

return (
<>
<div className='row mb-5'>
<div className='col-12 col-md-6'>
<div className='form-group'>
<label>Department</label>
<select
className='form-control form-shadow'
name='orgs'
onChange={orgSelected}
>
{orgs.map((org) => (
<option key={org.code} value={org.code}>
{org.name}
</option>
))}
</select>
</div>
</div>
<div className='col-12 col-md-6'>
<div className='form-group'>
<label>SFNs</label>
<select
className='form-control form-shadow'
name='sfns'
onChange={sfnSelected}
>
{sfns.map((sfnRecord) => (
<option key={sfnRecord.sfn} value={sfnRecord.sfn}>
{sfnRecord.description}
</option>
))}
</select>
</div>
</div>
</div>
<div className='row mb-5'>
<div className='col-12 col-md-12'>
<div className='card'>
<ExpenseTable
loading={expensesLoading}
expenses={expenses}
></ExpenseTable>
</div>
</div>
</div>
</>
);
}
15 changes: 14 additions & 1 deletion src/AD419/ClientApp/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ export interface Organization {
code: string;
name: string;
}
export interface SFNRecord {
sfn: string;
description: string;
}

export interface Project {
project: string;
Expand All @@ -27,6 +31,15 @@ export interface Expense {
isAssociated: boolean;
}

export interface UngroupedExpense {
expenseId: number;
sfn: string;
project: string;
expenses: number;
orgR: string;
pi: string;
}

export interface ExpenseGrouping {
grouping: string;
showAssociated: boolean;
Expand Down Expand Up @@ -63,4 +76,4 @@ export interface SFNSummary {
lineDisplayDescriptor: string;
sfn: string;
total: number;
}
}
8 changes: 8 additions & 0 deletions src/AD419/ClientApp/src/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const groupBy = <T, K extends keyof any>(arr: T[], key: (i: T) => K) =>
Object.entries<T[]>(
arr.reduce((groups, item) => {
groups[key(item)] = groups[key(item)] || [];
groups[key(item)].push(item);
return groups;
}, {} as Record<K, T[]>)
);
40 changes: 40 additions & 0 deletions src/AD419/Controllers/ExpenseController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ public async Task<IActionResult> Get(string org, string grouping, bool showAssoc
commandType: CommandType.StoredProcedure));
}
}

[HttpGet("Ungrouped")]
public async Task<IActionResult> Ungrouped(string org, string sfn)
{
if (!await _permissionService.CanAccessDepartment(User.Identity.Name, org))
{
return Forbid();
}

using (var conn = _dbService.GetConnection())
{
return Ok(await conn.QueryAsync<UngroupedExpenseModel>("usp_getProjectExpenses",
new { OrgR = org, SFN = sfn }, commandType: CommandType.StoredProcedure));
}
}

[HttpGet("SFNs")]
public async Task<IActionResult> SFNs()
{
using (var conn = _dbService.GetConnection())
{
return Ok(await conn.QueryAsync<SFNModel>("usp_getSFN", commandType: CommandType.StoredProcedure));
}
}
}

public class ExpenseModel
Expand All @@ -47,4 +71,20 @@ public class ExpenseModel
public int Num { get; set; }
public bool IsAssociated { get; set; }
}

public class UngroupedExpenseModel
{
public int ExpenseId { get; set; }
public string SFN { get; set; }
public string Project { get; set; }
public decimal Expenses { get; set; }
public string OrgR { get; set; }
public string PI { get; set; }
}

public class SFNModel
{
public string SFN { get; set; }
public string Description { get; set; }
}
}

0 comments on commit 95f2ee6

Please sign in to comment.