-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #78 from ucdavis/swe/UngroupedExpenses
Add tab for displaying ungrouped expenses
- Loading branch information
Showing
9 changed files
with
320 additions
and
6 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
src/AD419/ClientApp/src/components/expenses/ExpenseTable.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
141
src/AD419/ClientApp/src/components/expenses/ExpensesContainer.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[]>) | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters