Merge pull request #161 from grant-project/contribution-admin
Contribution Admin
This commit is contained in:
commit
136c1ee763
|
@ -15,6 +15,9 @@ import ProposalDetail from 'components/ProposalDetail';
|
||||||
import RFPs from 'components/RFPs';
|
import RFPs from 'components/RFPs';
|
||||||
import RFPForm from 'components/RFPForm';
|
import RFPForm from 'components/RFPForm';
|
||||||
import RFPDetail from 'components/RFPDetail';
|
import RFPDetail from 'components/RFPDetail';
|
||||||
|
import Contributions from 'components/Contributions';
|
||||||
|
import ContributionForm from 'components/ContributionForm';
|
||||||
|
import ContributionDetail from 'components/ContributionDetail';
|
||||||
|
|
||||||
import 'styles/style.less';
|
import 'styles/style.less';
|
||||||
|
|
||||||
|
@ -41,6 +44,10 @@ class Routes extends React.Component<Props> {
|
||||||
<Route path="/rfps/:id/edit" component={RFPForm} />
|
<Route path="/rfps/:id/edit" component={RFPForm} />
|
||||||
<Route path="/rfps/:id" component={RFPDetail} />
|
<Route path="/rfps/:id" component={RFPDetail} />
|
||||||
<Route path="/rfps" component={RFPs} />
|
<Route path="/rfps" component={RFPs} />
|
||||||
|
<Route path="/contributions/new" component={ContributionForm} />
|
||||||
|
<Route path="/contributions/:id/edit" component={ContributionForm} />
|
||||||
|
<Route path="/contributions/:id" component={ContributionDetail} />
|
||||||
|
<Route path="/contributions" component={Contributions} />
|
||||||
<Route path="/emails/:type?" component={Emails} />
|
<Route path="/emails/:type?" component={Emails} />
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
.ContributionDetail {
|
||||||
|
&-controls {
|
||||||
|
&-control + &-control {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-deet {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
&-value {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-card,
|
||||||
|
.ant-alert,
|
||||||
|
.ant-collapse {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
button + button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Row, Col, Card, Button, Collapse, Input } from 'antd';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Back from 'components/Back';
|
||||||
|
import UserItem from 'components/Users/UserItem';
|
||||||
|
import ProposalItem from 'components/Proposals/ProposalItem';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class ContributionDetail extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
store.fetchContributionDetail(this.getIdFromQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.getIdFromQuery();
|
||||||
|
const { contributionDetail: c, contributionDetailFetching } = store;
|
||||||
|
|
||||||
|
if (!c || (c && c.id !== id) || contributionDetailFetching) {
|
||||||
|
return 'loading proposal...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDeetItem = (label: string, val: React.ReactNode) => (
|
||||||
|
<div className="ContributionDetail-deet">
|
||||||
|
<div className="ContributionDetail-deet-value">
|
||||||
|
{val}
|
||||||
|
</div>
|
||||||
|
<div className="ContributionDetail-deet-label">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ContributionDetail">
|
||||||
|
<Back to="/contributions" text="Contributions" />
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* MAIN */}
|
||||||
|
<Col span={18}>
|
||||||
|
<Collapse defaultActiveKey={['addresses', 'user', 'proposal']}>
|
||||||
|
<Collapse.Panel key="addresses" header="addresses">
|
||||||
|
<pre>{JSON.stringify(c.addresses, null, 4)}</pre>
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="user" header="user">
|
||||||
|
<UserItem {...c.user} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="proposal" header="proposal">
|
||||||
|
<ProposalItem {...c.proposal} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="json" header="json">
|
||||||
|
<pre>{JSON.stringify(c, null, 4)}</pre>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE */}
|
||||||
|
<Col span={6}>
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<Card size="small" className="ContributionDetail-controls">
|
||||||
|
<Link to={`/contributions/${id}/edit`}>
|
||||||
|
<Button type="primary" block>Edit</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* DETAILS */}
|
||||||
|
<Card title="Details" size="small">
|
||||||
|
{renderDeetItem('id', c.id)}
|
||||||
|
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
|
||||||
|
{renderDeetItem('status', c.status)}
|
||||||
|
{renderDeetItem('amount', c.amount)}
|
||||||
|
{renderDeetItem('txid', c.txId
|
||||||
|
? <Input size="small" value={c.txId} readOnly />
|
||||||
|
: <em>N/A</em>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIdFromQuery = () => {
|
||||||
|
return Number(this.props.match.params.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(view(ContributionDetail));
|
|
@ -0,0 +1,9 @@
|
||||||
|
.ContributionForm {
|
||||||
|
&-buttons {
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Form, Input, Button, message, Spin } from 'antd';
|
||||||
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
|
import { FormComponentProps } from 'antd/lib/form';
|
||||||
|
import { ContributionArgs, CONTRIBUTION_STATUS } from 'src/types';
|
||||||
|
import Back from 'components/Back';
|
||||||
|
import store from 'src/store';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
type Props = FormComponentProps & RouteComponentProps<{ id?: string }>;
|
||||||
|
|
||||||
|
class ContributionForm extends React.Component<Props> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
const id = this.getId();
|
||||||
|
if (id) {
|
||||||
|
store.fetchContributionDetail(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { getFieldDecorator } = this.props.form;
|
||||||
|
|
||||||
|
let defaults: Partial<ContributionArgs> = {
|
||||||
|
proposalId: '',
|
||||||
|
userId: '',
|
||||||
|
amount: '',
|
||||||
|
txId: '',
|
||||||
|
};
|
||||||
|
const id = this.getId();
|
||||||
|
const contribution = this.getContribution();
|
||||||
|
if (id) {
|
||||||
|
if (store.contributionDetailFetching) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
if (!contribution) {
|
||||||
|
return <Exception type="404" desc="This contribution does not exist" />;
|
||||||
|
}
|
||||||
|
defaults = {
|
||||||
|
proposalId: contribution.proposal.proposalId,
|
||||||
|
userId: contribution.user.userid,
|
||||||
|
amount: contribution.amount,
|
||||||
|
txId: contribution.txId || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="ContributionForm" layout="vertical" onSubmit={this.handleSubmit}>
|
||||||
|
<Back to="/contributions" text="Contributions" />
|
||||||
|
<Form.Item label="Proposal ID">
|
||||||
|
{getFieldDecorator('proposalId', {
|
||||||
|
initialValue: defaults.proposalId,
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'Proposal ID is required' },
|
||||||
|
],
|
||||||
|
})(
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="proposalId"
|
||||||
|
placeholder="Must be an existing proposal id"
|
||||||
|
autoFocus
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="User ID">
|
||||||
|
{getFieldDecorator('userId', {
|
||||||
|
initialValue: defaults.userId,
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'User ID is required' },
|
||||||
|
],
|
||||||
|
})(
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="userId"
|
||||||
|
placeholder="Must be an existing user id"
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Contribution amount">
|
||||||
|
{getFieldDecorator('amount', {
|
||||||
|
initialValue: defaults.amount,
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'Must have an amount specified' },
|
||||||
|
],
|
||||||
|
})(
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="amount"
|
||||||
|
placeholder="Amount in ZEC, no more than 4 decimals"
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Transaction ID"
|
||||||
|
help={`
|
||||||
|
Providing a txid will set status to CONFIRMED, leaving
|
||||||
|
blank will set status to PENDING.
|
||||||
|
`}>
|
||||||
|
{getFieldDecorator('txId', {
|
||||||
|
initialValue: defaults.txId,
|
||||||
|
})(
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
name="amount"
|
||||||
|
placeholder="e.g. 7ae7bc1759a2bb9aa40b34daa3..."
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="ContributionForm-buttons">
|
||||||
|
<Button type="primary" htmlType="submit" size="large">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button type="ghost" size="large">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getId = () => {
|
||||||
|
const id = this.props.match.params.id;
|
||||||
|
if (id) {
|
||||||
|
return parseInt(id, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private getContribution = () => {
|
||||||
|
const id = this.getId();
|
||||||
|
if (id && store.contributionDetail && store.contributionDetail.id === id) {
|
||||||
|
return store.contributionDetail;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
const id = this.getId();
|
||||||
|
const args = {
|
||||||
|
...values,
|
||||||
|
status: values.txId
|
||||||
|
? CONTRIBUTION_STATUS.CONFIRMED
|
||||||
|
: CONTRIBUTION_STATUS.PENDING,
|
||||||
|
};
|
||||||
|
let msg;
|
||||||
|
if (id) {
|
||||||
|
await store.editContribution(id, args);
|
||||||
|
msg = 'Successfully updated contribution';
|
||||||
|
} else {
|
||||||
|
await store.createContribution(args);
|
||||||
|
msg = 'Successfully created contribution';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.contributionSaved) {
|
||||||
|
message.success(msg, 3);
|
||||||
|
this.props.history.replace('/contributions');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Form.create()(withRouter(view(ContributionForm)));
|
|
@ -0,0 +1,31 @@
|
||||||
|
.ContributionItem {
|
||||||
|
& h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
|
||||||
|
> small {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-tag {
|
||||||
|
vertical-align: text-top;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(#000, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { List, Tag, Tooltip } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Contribution } from 'src/types';
|
||||||
|
import { CONTRIBUTION_STATUSES, getStatusById } from 'util/statuses';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import './ContributionItem.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contribution: Contribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ContributionItem extends React.PureComponent<Props> {
|
||||||
|
render() {
|
||||||
|
const { id, amount, dateCreated, proposal, user } = this.props.contribution;
|
||||||
|
const status = getStatusById(CONTRIBUTION_STATUSES, this.props.contribution.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
className="ContributionItem"
|
||||||
|
actions={[<Link key="edit" to={`/contributions/${id}/edit`}>edit</Link>]}
|
||||||
|
>
|
||||||
|
<Link to={`/contributions/${id}`}>
|
||||||
|
<h2>
|
||||||
|
{user.displayName} <small>for</small> {proposal.title}
|
||||||
|
<Tooltip title={status.hint}>
|
||||||
|
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<span><strong>Amount:</strong> {amount} ZEC</span>
|
||||||
|
<span><strong>Created:</strong> {formatDateSeconds(dateCreated)}</span>
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import store from 'src/store';
|
||||||
|
import Pageable from 'components/Pageable';
|
||||||
|
import ContributionItem from './ContributionItem';
|
||||||
|
import { Contribution } from 'src/types';
|
||||||
|
import { PROPOSAL_STATUSES } from 'util/statuses';
|
||||||
|
|
||||||
|
class Contributions extends React.Component<{}> {
|
||||||
|
render() {
|
||||||
|
const { page } = store.contributions;
|
||||||
|
// NOTE: sync with /backend ... pagination.py ContributionPagination.SORT_MAP
|
||||||
|
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'AMOUNT:DESC', 'AMOUNT:ASC'];
|
||||||
|
return (
|
||||||
|
<Pageable
|
||||||
|
page={page}
|
||||||
|
statuses={PROPOSAL_STATUSES}
|
||||||
|
sorts={sorts}
|
||||||
|
searchPlaceholder="Search amount or txid"
|
||||||
|
controlsExtra={
|
||||||
|
<Link to="/contributions/new">
|
||||||
|
<Button icon="plus">Create a contribution</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
renderItem={(c: Contribution) =>
|
||||||
|
<ContributionItem key={c.id} contribution={c} />
|
||||||
|
}
|
||||||
|
handleSearch={store.fetchContributions}
|
||||||
|
handleChangeQuery={store.setContributionPageQuery}
|
||||||
|
handleResetQuery={store.resetContributionPageQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(Contributions);
|
|
@ -1,4 +1,4 @@
|
||||||
.Proposals {
|
.Pageable {
|
||||||
&-controls {
|
&-controls {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@ -10,6 +10,10 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-extra {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-filters {
|
&-filters {
|
|
@ -0,0 +1,180 @@
|
||||||
|
import React from 'react';
|
||||||
|
import qs from 'query-string';
|
||||||
|
import { uniq, without } from 'lodash';
|
||||||
|
import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd';
|
||||||
|
import { ClickParam } from 'antd/lib/menu';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { PageData, PageQuery } from 'src/types';
|
||||||
|
import { StatusSoT, getStatusById } from 'util/statuses';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface OwnProps<T> {
|
||||||
|
page: PageData<T>;
|
||||||
|
statuses: Array<StatusSoT<any>>;
|
||||||
|
sorts: string[];
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
controlsExtra?: React.ReactNode;
|
||||||
|
renderItem(item: T): React.ReactNode;
|
||||||
|
handleSearch(): void;
|
||||||
|
handleChangeQuery(query: Partial<PageQuery>): void;
|
||||||
|
handleResetQuery(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props<T> = OwnProps<T> & RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class Pageable<T> extends React.Component<Props<T>, {}> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.setQueryFromUrl();
|
||||||
|
this.props.handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { page, statuses, sorts, renderItem, searchPlaceholder, controlsExtra } = this.props;
|
||||||
|
const loading = !page.fetched || page.fetching;
|
||||||
|
const filters = page.filters
|
||||||
|
.filter(f => f.startsWith('STATUS_'))
|
||||||
|
.map(f => f.replace('STATUS_', ''));
|
||||||
|
|
||||||
|
const statusFilterMenu = (
|
||||||
|
<Menu onClick={this.handleFilterClick}>
|
||||||
|
{statuses.map(s => (
|
||||||
|
<Menu.Item key={s.id}>{s.filterDisplay}</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortMenu = (
|
||||||
|
<Menu onClick={this.handleSortClick}>
|
||||||
|
{sorts.map(s => (
|
||||||
|
<Menu.Item key={s}>{s}</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Pageable">
|
||||||
|
<div className="Pageable-controls">
|
||||||
|
<Input.Search
|
||||||
|
className="Pageable-controls-search"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
onSearch={this.handleSearch}
|
||||||
|
/>
|
||||||
|
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
|
||||||
|
<Button>
|
||||||
|
Filter <Icon type="down" />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown overlay={sortMenu} trigger={['click']}>
|
||||||
|
<Button>
|
||||||
|
{'Sort ' + page.sort} <Icon type="down" />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Button title="refresh" icon="reload" onClick={this.props.handleSearch} />
|
||||||
|
|
||||||
|
{controlsExtra && (
|
||||||
|
<div className="Pageable-controls-extra">
|
||||||
|
{controlsExtra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{page.search && (
|
||||||
|
<div>
|
||||||
|
Search: <b>{page.search}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!page.filters.length && (
|
||||||
|
<div className="Pageable-filters">
|
||||||
|
Filters:{' '}
|
||||||
|
{filters.map(sf => (
|
||||||
|
<Tag
|
||||||
|
key={sf}
|
||||||
|
onClose={() => this.handleFilterClose(sf)}
|
||||||
|
color={getStatusById(statuses, sf).tagColor}
|
||||||
|
closable
|
||||||
|
>
|
||||||
|
status: {sf}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{filters.length > 1 && (
|
||||||
|
<Tag key="clear" onClick={this.handleFilterClear}>
|
||||||
|
clear
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List
|
||||||
|
className="Pageable-list"
|
||||||
|
bordered
|
||||||
|
dataSource={page.items}
|
||||||
|
loading={loading}
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="Pageable-pagination">
|
||||||
|
<Pagination
|
||||||
|
current={page.page}
|
||||||
|
total={page.total}
|
||||||
|
pageSize={page.pageSize}
|
||||||
|
onChange={this.handlePageChange}
|
||||||
|
hideOnSinglePage={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setQueryFromUrl = () => {
|
||||||
|
const { history, statuses, match, handleResetQuery, handleChangeQuery } = this.props;
|
||||||
|
const parsed = qs.parse(history.location.search);
|
||||||
|
|
||||||
|
// status filter
|
||||||
|
if (parsed.status) {
|
||||||
|
if (getStatusById(statuses, parsed.status)) {
|
||||||
|
// here we reset to normal page query params, we might want
|
||||||
|
// to do this every time we load or leave the component
|
||||||
|
handleResetQuery();
|
||||||
|
handleChangeQuery({ filters: [`STATUS_${parsed.status}`] });
|
||||||
|
}
|
||||||
|
history.replace(match.url); // remove qs
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleSortClick = (e: ClickParam) => {
|
||||||
|
this.props.handleChangeQuery({ sort: e.key });
|
||||||
|
this.props.handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleFilterClick = (e: ClickParam) => {
|
||||||
|
const { page, handleChangeQuery, handleSearch } = this.props;
|
||||||
|
handleChangeQuery({
|
||||||
|
filters: uniq([`STATUS_${e.key}`, ...page.filters])
|
||||||
|
});
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleFilterClose = (filter: string) => {
|
||||||
|
const { page, handleChangeQuery, handleSearch } = this.props;
|
||||||
|
handleChangeQuery({ filters: without(page.filters, `STATUS_${filter}`) });
|
||||||
|
handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleFilterClear = () => {
|
||||||
|
this.props.handleChangeQuery({ filters: [] });
|
||||||
|
this.props.handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleSearch = (s: string) => {
|
||||||
|
this.props.handleChangeQuery({ search: s });
|
||||||
|
this.props.handleSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePageChange = (p: number) => {
|
||||||
|
this.props.handleChangeQuery({ page: p });
|
||||||
|
this.props.handleSearch();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Pageable);
|
|
@ -1,161 +1,29 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import qs from 'query-string';
|
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd';
|
|
||||||
import { ClickParam } from 'antd/lib/menu';
|
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import ProposalItem from './ProposalItem';
|
import ProposalItem from './ProposalItem';
|
||||||
import { PROPOSAL_STATUS, Proposal } from 'src/types';
|
import Pageable from 'components/Pageable';
|
||||||
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
|
import { Proposal } from 'src/types';
|
||||||
import './index.less';
|
import { PROPOSAL_STATUSES } from 'util/statuses';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
|
||||||
|
|
||||||
class ProposalsNaked extends React.Component<Props, {}> {
|
|
||||||
componentDidMount() {
|
|
||||||
this.setStoreFromQueryString();
|
|
||||||
store.fetchProposals();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
class Proposals extends React.Component<{}> {
|
||||||
render() {
|
render() {
|
||||||
const { page } = store.proposals;
|
const { page } = store.proposals;
|
||||||
const loading = !page.fetched || page.fetching;
|
// NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP
|
||||||
const filters = page.filters
|
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'];
|
||||||
.filter(f => f.startsWith('STATUS_'))
|
|
||||||
.map(f => f.replace('STATUS_', '') as PROPOSAL_STATUS);
|
|
||||||
|
|
||||||
const statusFilterMenu = (
|
|
||||||
<Menu onClick={this.handleFilterClick}>
|
|
||||||
{PROPOSAL_STATUSES.map(f => (
|
|
||||||
<Menu.Item key={f.id}>{f.filterDisplay}</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortMenu = (
|
|
||||||
<Menu onClick={this.handleSortClick}>
|
|
||||||
{/* NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP */}
|
|
||||||
{['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'].map(s => (
|
|
||||||
<Menu.Item key={s}>{s}</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Proposals">
|
<Pageable
|
||||||
<div className="Proposals-controls">
|
page={page}
|
||||||
<Input.Search
|
statuses={PROPOSAL_STATUSES}
|
||||||
className="Proposals-controls-search"
|
sorts={sorts}
|
||||||
placeholder="search titles"
|
searchPlaceholder="Search proposal titles"
|
||||||
onSearch={this.handleSearch}
|
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
|
||||||
/>
|
handleSearch={store.fetchProposals}
|
||||||
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
|
handleChangeQuery={store.setProposalPageQuery}
|
||||||
<Button>
|
handleResetQuery={store.resetProposalPageQuery}
|
||||||
Filter <Icon type="down" />
|
/>
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
<Dropdown overlay={sortMenu} trigger={['click']}>
|
|
||||||
<Button>
|
|
||||||
{'Sort ' + store.proposals.page.sort} <Icon type="down" />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
<Button title="refresh" icon="reload" onClick={store.fetchProposals} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{page.search && (
|
|
||||||
<div>
|
|
||||||
Search: <b>{page.search}</b>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!page.filters.length && (
|
|
||||||
<div className="Proposals-filters">
|
|
||||||
Filters:{' '}
|
|
||||||
{filters.map(sf => (
|
|
||||||
<Tag
|
|
||||||
key={sf}
|
|
||||||
onClose={() => this.handleFilterClose(sf)}
|
|
||||||
color={getStatusById(PROPOSAL_STATUSES, sf).tagColor}
|
|
||||||
closable
|
|
||||||
>
|
|
||||||
status: {sf}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{filters.length > 1 && (
|
|
||||||
<Tag key="clear" onClick={this.handleFilterClear}>
|
|
||||||
clear
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<List
|
|
||||||
className="Proposals-list"
|
|
||||||
bordered
|
|
||||||
dataSource={page.items}
|
|
||||||
loading={loading}
|
|
||||||
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="Proposals-pagination">
|
|
||||||
<Pagination
|
|
||||||
current={page.page}
|
|
||||||
total={page.total}
|
|
||||||
pageSize={page.pageSize}
|
|
||||||
onChange={this.handlePageChange}
|
|
||||||
hideOnSinglePage={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStoreFromQueryString = () => {
|
|
||||||
const parsed = qs.parse(this.props.history.location.search);
|
|
||||||
|
|
||||||
// status filter
|
|
||||||
if (parsed.status) {
|
|
||||||
if (getStatusById(PROPOSAL_STATUSES, parsed.status)) {
|
|
||||||
// here we reset to normal page query params, we might want
|
|
||||||
// to do this every time we load or leave the component
|
|
||||||
store.resetProposalPageQuery();
|
|
||||||
store.addProposalPageFilter('STATUS_' + parsed.status);
|
|
||||||
}
|
|
||||||
this.props.history.replace(this.props.match.url); // remove qs
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSortClick = (e: ClickParam) => {
|
|
||||||
store.proposals.page.sort = e.key;
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleFilterClick = (e: ClickParam) => {
|
|
||||||
store.addProposalPageFilter('STATUS_' + e.key);
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleFilterClose = (filter: PROPOSAL_STATUS) => {
|
|
||||||
store.removeProposalPageFilter('STATUS_' + filter);
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleFilterClear = () => {
|
|
||||||
store.proposals.page.filters = [];
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSearch = (s: string) => {
|
|
||||||
store.proposals.page.search = s;
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
|
|
||||||
private handlePageChange = (p: number) => {
|
|
||||||
store.proposals.page.page = p;
|
|
||||||
store.fetchProposals();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Proposals = withRouter(view(ProposalsNaked));
|
export default view(Proposals);
|
||||||
export default Proposals;
|
|
||||||
|
|
|
@ -57,6 +57,12 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">RFPs</span>
|
<span className="nav-text">RFPs</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="contributions">
|
||||||
|
<Link to="/contributions">
|
||||||
|
<Icon type="dollar" />
|
||||||
|
<span className="nav-text">Contributions</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="emails">
|
<Menu.Item key="emails">
|
||||||
<Link to="/emails">
|
<Link to="/emails">
|
||||||
<Icon type="mail" />
|
<Icon type="mail" />
|
||||||
|
|
|
@ -1,7 +1,17 @@
|
||||||
import { uniq, without, pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import { store } from 'react-easy-state';
|
import { store } from 'react-easy-state';
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { User, Proposal, RFP, RFPArgs, EmailExample, PageQuery } from './types';
|
import {
|
||||||
|
User,
|
||||||
|
Proposal,
|
||||||
|
Contribution,
|
||||||
|
ContributionArgs,
|
||||||
|
RFP,
|
||||||
|
RFPArgs,
|
||||||
|
EmailExample,
|
||||||
|
PageQuery,
|
||||||
|
PageData,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
@ -48,9 +58,7 @@ async function deleteUser(id: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProposals(params: Partial<PageQuery>) {
|
async function fetchProposals(params: Partial<PageQuery>) {
|
||||||
const { data } = await api.get('/admin/proposals', {
|
const { data } = await api.get('/admin/proposals', { params });
|
||||||
params,
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,8 +109,29 @@ async function deleteRFP(id: number) {
|
||||||
await api.delete(`/admin/rfps/${id}`);
|
await api.delete(`/admin/rfps/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getContributions(params: PageQuery) {
|
||||||
|
const { data } = await api.get('/admin/contributions', { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContribution(id: number) {
|
||||||
|
const { data } = await api.get(`/admin/contributions/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createContribution(args: ContributionArgs) {
|
||||||
|
const { data } = await api.post('/admin/contributions', args);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editContribution(id: number, args: ContributionArgs) {
|
||||||
|
const { data } = await api.put(`/admin/contributions/${id}`, args);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// STORE
|
// STORE
|
||||||
const app = store({
|
const app = store({
|
||||||
|
/*** DATA ***/
|
||||||
hasCheckedLogin: false,
|
hasCheckedLogin: false,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
loginError: '',
|
loginError: '',
|
||||||
|
@ -124,21 +153,11 @@ const app = store({
|
||||||
userDeleted: false,
|
userDeleted: false,
|
||||||
|
|
||||||
proposals: {
|
proposals: {
|
||||||
page: {
|
page: createDefaultPageData<Proposal>('CREATED:DESC'),
|
||||||
page: 1,
|
|
||||||
search: '',
|
|
||||||
sort: 'CREATED:DESC',
|
|
||||||
filters: [] as string[],
|
|
||||||
pageSize: 0,
|
|
||||||
total: 0,
|
|
||||||
items: [] as Proposal[],
|
|
||||||
fetching: false,
|
|
||||||
fetched: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
proposalDetailFetching: false,
|
|
||||||
proposalDetail: null as null | Proposal,
|
proposalDetail: null as null | Proposal,
|
||||||
|
proposalDetailFetching: false,
|
||||||
proposalDetailApproving: false,
|
proposalDetailApproving: false,
|
||||||
|
|
||||||
rfps: [] as RFP[],
|
rfps: [] as RFP[],
|
||||||
|
@ -149,8 +168,19 @@ const app = store({
|
||||||
rfpDeleting: false,
|
rfpDeleting: false,
|
||||||
rfpDeleted: false,
|
rfpDeleted: false,
|
||||||
|
|
||||||
|
contributions: {
|
||||||
|
page: createDefaultPageData<Contribution>('CREATED:DESC'),
|
||||||
|
},
|
||||||
|
|
||||||
|
contributionDetail: null as null | Contribution,
|
||||||
|
contributionDetailFetching: false,
|
||||||
|
contributionSaving: false,
|
||||||
|
contributionSaved: false,
|
||||||
|
|
||||||
emailExamples: {} as { [type: string]: EmailExample },
|
emailExamples: {} as { [type: string]: EmailExample },
|
||||||
|
|
||||||
|
/*** ACTIONS ***/
|
||||||
|
|
||||||
removeGeneralError(i: number) {
|
removeGeneralError(i: number) {
|
||||||
app.generalError.splice(i, 1);
|
app.generalError.splice(i, 1);
|
||||||
},
|
},
|
||||||
|
@ -165,6 +195,8 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
async checkLogin() {
|
async checkLogin() {
|
||||||
app.isLoggedIn = await checkLogin();
|
app.isLoggedIn = await checkLogin();
|
||||||
app.hasCheckedLogin = true;
|
app.hasCheckedLogin = true;
|
||||||
|
@ -197,6 +229,8 @@ const app = store({
|
||||||
app.statsFetching = false;
|
app.statsFetching = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Users
|
||||||
|
|
||||||
async fetchUsers() {
|
async fetchUsers() {
|
||||||
app.usersFetching = true;
|
app.usersFetching = true;
|
||||||
try {
|
try {
|
||||||
|
@ -232,6 +266,8 @@ const app = store({
|
||||||
app.userDeleting = false;
|
app.userDeleting = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Proposals
|
||||||
|
|
||||||
async fetchProposals() {
|
async fetchProposals() {
|
||||||
app.proposals.page.fetching = true;
|
app.proposals.page.fetching = true;
|
||||||
try {
|
try {
|
||||||
|
@ -247,6 +283,13 @@ const app = store({
|
||||||
app.proposals.page.fetching = false;
|
app.proposals.page.fetching = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setProposalPageQuery(query: Partial<PageQuery>) {
|
||||||
|
app.proposals.page = {
|
||||||
|
...app.proposals.page,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
getProposalPageQuery() {
|
getProposalPageQuery() {
|
||||||
return pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
return pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
||||||
},
|
},
|
||||||
|
@ -258,16 +301,6 @@ const app = store({
|
||||||
app.proposals.page.filters = [];
|
app.proposals.page.filters = [];
|
||||||
},
|
},
|
||||||
|
|
||||||
addProposalPageFilter(f: string) {
|
|
||||||
const current = app.proposals.page.filters;
|
|
||||||
app.proposals.page.filters = uniq([f, ...current]);
|
|
||||||
},
|
|
||||||
|
|
||||||
removeProposalPageFilter(f: string) {
|
|
||||||
const current = app.proposals.page.filters;
|
|
||||||
app.proposals.page.filters = without(current, f);
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchProposalDetail(id: number) {
|
async fetchProposalDetail(id: number) {
|
||||||
app.proposalDetailFetching = true;
|
app.proposalDetailFetching = true;
|
||||||
try {
|
try {
|
||||||
|
@ -322,6 +355,8 @@ const app = store({
|
||||||
app.proposalDetailApproving = false;
|
app.proposalDetailApproving = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Email
|
||||||
|
|
||||||
async getEmailExample(type: string) {
|
async getEmailExample(type: string) {
|
||||||
try {
|
try {
|
||||||
const example = await getEmailExample(type);
|
const example = await getEmailExample(type);
|
||||||
|
@ -334,6 +369,8 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// RFPs
|
||||||
|
|
||||||
async fetchRFPs() {
|
async fetchRFPs() {
|
||||||
app.rfpsFetching = true;
|
app.rfpsFetching = true;
|
||||||
try {
|
try {
|
||||||
|
@ -382,8 +419,78 @@ const app = store({
|
||||||
}
|
}
|
||||||
app.rfpDeleting = false;
|
app.rfpDeleting = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Contributions
|
||||||
|
|
||||||
|
async fetchContributions() {
|
||||||
|
app.contributions.page.fetching = true;
|
||||||
|
try {
|
||||||
|
const page = await getContributions(app.getContributionPageQuery());
|
||||||
|
app.contributions.page = {
|
||||||
|
...app.contributions.page,
|
||||||
|
...page,
|
||||||
|
fetched: true,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.contributions.page.fetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
setContributionPageQuery(query: Partial<PageQuery>) {
|
||||||
|
app.contributions.page = {
|
||||||
|
...app.contributions.page,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getContributionPageQuery() {
|
||||||
|
return pick(app.contributions.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetContributionPageQuery() {
|
||||||
|
app.contributions.page.page = 1;
|
||||||
|
app.contributions.page.search = '';
|
||||||
|
app.contributions.page.sort = 'CREATED:DESC';
|
||||||
|
app.contributions.page.filters = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchContributionDetail(id: number) {
|
||||||
|
app.contributionDetailFetching = true;
|
||||||
|
try {
|
||||||
|
app.contributionDetail = await getContribution(id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.contributionDetailFetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async editContribution(id: number, args: ContributionArgs) {
|
||||||
|
app.contributionSaving = true;
|
||||||
|
app.contributionSaved = false;
|
||||||
|
try {
|
||||||
|
await editContribution(id, args);
|
||||||
|
app.contributionSaved = true;
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.contributionSaving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createContribution(args: ContributionArgs) {
|
||||||
|
app.contributionSaving = true;
|
||||||
|
app.contributionSaved = false;
|
||||||
|
try {
|
||||||
|
await createContribution(args);
|
||||||
|
app.contributionSaved = true;
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.contributionSaving = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Utils
|
||||||
function handleApiError(e: AxiosError) {
|
function handleApiError(e: AxiosError) {
|
||||||
if (e.response && e.response.data!.message) {
|
if (e.response && e.response.data!.message) {
|
||||||
app.generalError.push(e.response!.data.message);
|
app.generalError.push(e.response!.data.message);
|
||||||
|
@ -394,6 +501,21 @@ function handleApiError(e: AxiosError) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDefaultPageData<T>(sort: string): PageData<T> {
|
||||||
|
return {
|
||||||
|
sort,
|
||||||
|
page: 1,
|
||||||
|
search: '',
|
||||||
|
filters: [] as string[],
|
||||||
|
pageSize: 0,
|
||||||
|
total: 0,
|
||||||
|
items: [] as T[],
|
||||||
|
fetching: false,
|
||||||
|
fetched: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to window for inspection
|
||||||
(window as any).appStore = app;
|
(window as any).appStore = app;
|
||||||
|
|
||||||
// check login status periodically
|
// check login status periodically
|
||||||
|
|
|
@ -76,14 +76,32 @@ export interface Comment {
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
// NOTE: sync with backend/utils/enums.py
|
||||||
|
export enum CONTRIBUTION_STATUS {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
}
|
||||||
export interface Contribution {
|
export interface Contribution {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: CONTRIBUTION_STATUS;
|
||||||
txId: null | string;
|
txId: null | string;
|
||||||
amount: string;
|
amount: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
user: User;
|
user: User;
|
||||||
proposal: Proposal;
|
proposal: Proposal;
|
||||||
|
addresses: {
|
||||||
|
transparent: string;
|
||||||
|
sprout: string;
|
||||||
|
memo: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface ContributionArgs {
|
||||||
|
proposalId: string | number;
|
||||||
|
userId: string | number;
|
||||||
|
amount: string;
|
||||||
|
status: string;
|
||||||
|
txId?: string;
|
||||||
}
|
}
|
||||||
export interface User {
|
export interface User {
|
||||||
accountAddress: string;
|
accountAddress: string;
|
||||||
|
@ -123,3 +141,11 @@ export interface PageQuery {
|
||||||
search: string;
|
search: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageData<T> extends PageQuery {
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
items: T[];
|
||||||
|
fetching: boolean;
|
||||||
|
fetched: boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { PROPOSAL_STATUS, RFP_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS, RFP_STATUS, CONTRIBUTION_STATUS } from 'src/types';
|
||||||
|
|
||||||
export interface StatusSoT<E> {
|
export interface StatusSoT<E> {
|
||||||
id: E;
|
id: E;
|
||||||
|
@ -86,6 +86,30 @@ export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CONTRIBUTION_STATUSES: Array<StatusSoT<CONTRIBUTION_STATUS>> = [
|
||||||
|
{
|
||||||
|
id: CONTRIBUTION_STATUS.PENDING,
|
||||||
|
filterDisplay: 'Status: pending',
|
||||||
|
tagDisplay: 'Pending',
|
||||||
|
tagColor: '#ffaa00',
|
||||||
|
hint: 'Contribution is currently waiting to be sent and confirmed on chain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CONTRIBUTION_STATUS.CONFIRMED,
|
||||||
|
filterDisplay: 'Status: confirmed',
|
||||||
|
tagDisplay: 'Confirmed',
|
||||||
|
tagColor: '#108ee9',
|
||||||
|
hint: 'Contribution was confirmed on chain with multiple block confirmations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CONTRIBUTION_STATUS.DELETED,
|
||||||
|
filterDisplay: 'Status: deleted',
|
||||||
|
tagDisplay: 'Closed',
|
||||||
|
tagColor: '#eb4118',
|
||||||
|
hint: 'User deleted the contribution before it was sent or confirmed',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function getStatusById<E>(statuses: Array<StatusSoT<E>>, id: E) {
|
export function getStatusById<E>(statuses: Array<StatusSoT<E>>, id: E) {
|
||||||
const result = statuses.find(s => s.id === id);
|
const result = statuses.find(s => s.id === id);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
|
from decimal import Decimal
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema
|
||||||
from grant.email.send import generate_email
|
from grant.email.send import generate_email
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
|
@ -9,12 +10,13 @@ from grant.proposal.models import (
|
||||||
ProposalContribution,
|
ProposalContribution,
|
||||||
proposals_schema,
|
proposals_schema,
|
||||||
proposal_schema,
|
proposal_schema,
|
||||||
|
proposal_contribution_schema,
|
||||||
user_proposal_contributions_schema,
|
user_proposal_contributions_schema,
|
||||||
)
|
)
|
||||||
from grant.user.models import User, users_schema, user_schema
|
from grant.user.models import User, users_schema, user_schema
|
||||||
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
||||||
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
||||||
from grant.utils.enums import ProposalStatus
|
from grant.utils.enums import ProposalStatus, ContributionStatus
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
|
@ -279,3 +281,109 @@ def delete_rfp(rfp_id):
|
||||||
db.session.delete(rfp)
|
db.session.delete(rfp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return None, 200
|
return None, 200
|
||||||
|
|
||||||
|
|
||||||
|
# Contributions
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/contributions', methods=['GET'])
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('page', type=int, required=False),
|
||||||
|
parameter('filters', type=list, required=False),
|
||||||
|
parameter('search', type=str, required=False),
|
||||||
|
parameter('sort', type=str, required=False)
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def get_contributions(page, filters, search, sort):
|
||||||
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
|
page = pagination.contribution(
|
||||||
|
page=page,
|
||||||
|
filters=filters_workaround,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/contributions', methods=['POST'])
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('proposalId', type=int, required=True),
|
||||||
|
parameter('userId', type=int, required=True),
|
||||||
|
parameter('status', type=str, required=True),
|
||||||
|
parameter('amount', type=str, required=True),
|
||||||
|
parameter('txId', type=str, required=False),
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
||||||
|
# Some fields set manually since we're admin, and normally don't do this
|
||||||
|
contribution = ProposalContribution(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
user_id=user_id,
|
||||||
|
amount=amount,
|
||||||
|
)
|
||||||
|
contribution.status = status
|
||||||
|
contribution.tx_id = tx_id
|
||||||
|
|
||||||
|
db.session.add(contribution)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_contribution_schema.dump(contribution), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/contributions/<contribution_id>', methods=['GET'])
|
||||||
|
@endpoint.api()
|
||||||
|
@admin_auth_required
|
||||||
|
def get_contribution(contribution_id):
|
||||||
|
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
|
||||||
|
if not contribution:
|
||||||
|
return {"message": "No contribution matching that id"}, 404
|
||||||
|
|
||||||
|
return proposal_contribution_schema.dump(contribution), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/contributions/<contribution_id>', methods=['PUT'])
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('proposalId', type=int, required=False),
|
||||||
|
parameter('userId', type=int, required=False),
|
||||||
|
parameter('status', type=str, required=False),
|
||||||
|
parameter('amount', type=str, required=False),
|
||||||
|
parameter('txId', type=str, required=False),
|
||||||
|
)
|
||||||
|
@admin_auth_required
|
||||||
|
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id):
|
||||||
|
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
|
||||||
|
if not contribution:
|
||||||
|
return {"message": "No contribution matching that id"}, 404
|
||||||
|
|
||||||
|
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
|
||||||
|
|
||||||
|
# Proposal ID (must belong to an existing proposal)
|
||||||
|
if proposal_id:
|
||||||
|
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching that id"}, 400
|
||||||
|
contribution.proposal_id = proposal_id
|
||||||
|
# User ID (must belong to an existing user)
|
||||||
|
if user_id:
|
||||||
|
user = User.query.filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
return {"message": "No user matching that id"}, 400
|
||||||
|
contribution.user_id = user_id
|
||||||
|
# Status (must be in list of statuses)
|
||||||
|
if status:
|
||||||
|
if not ContributionStatus.includes(status):
|
||||||
|
return {"message": "Invalid status"}, 400
|
||||||
|
contribution.status = status
|
||||||
|
# Amount (must be a Decimal parseable)
|
||||||
|
if amount:
|
||||||
|
try:
|
||||||
|
contribution.amount = str(Decimal(amount))
|
||||||
|
except:
|
||||||
|
return {"message": "Amount could not be parsed as number"}, 400
|
||||||
|
# Transaction ID (no validation)
|
||||||
|
if tx_id:
|
||||||
|
contribution.tx_id = tx_id
|
||||||
|
|
||||||
|
db.session.add(contribution)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_contribution_schema.dump(contribution), 200
|
||||||
|
|
||||||
|
|
|
@ -132,6 +132,46 @@ class ProposalContribution(db.Model):
|
||||||
.order_by(ProposalContribution.date_created.desc()) \
|
.order_by(ProposalContribution.date_created.desc()) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate(contribution):
|
||||||
|
proposal_id = contribution.get('proposal_id')
|
||||||
|
user_id = contribution.get('user_id')
|
||||||
|
status = contribution.get('status')
|
||||||
|
amount = contribution.get('amount')
|
||||||
|
tx_id = contribution.get('tx_id')
|
||||||
|
|
||||||
|
# Proposal ID (must belong to an existing proposal)
|
||||||
|
if proposal_id:
|
||||||
|
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
raise ValidationException('No proposal matching that ID')
|
||||||
|
contribution.proposal_id = proposal_id
|
||||||
|
else:
|
||||||
|
raise ValidationException('Proposal ID is required')
|
||||||
|
# User ID (must belong to an existing user)
|
||||||
|
if user_id:
|
||||||
|
user = User.query.filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise ValidationException('No user matching that ID')
|
||||||
|
contribution.user_id = user_id
|
||||||
|
else:
|
||||||
|
raise ValidationException('User ID is required')
|
||||||
|
# Status (must be in list of statuses)
|
||||||
|
if status:
|
||||||
|
if not ContributionStatus.includes(status):
|
||||||
|
raise ValidationException('Invalid status')
|
||||||
|
contribution.status = status
|
||||||
|
else:
|
||||||
|
raise ValidationException('Status is required')
|
||||||
|
# Amount (must be a Decimal parseable)
|
||||||
|
if amount:
|
||||||
|
try:
|
||||||
|
contribution.amount = str(Decimal(amount))
|
||||||
|
except:
|
||||||
|
raise ValidationException('Amount must be a number')
|
||||||
|
else:
|
||||||
|
raise ValidationException('Amount is required')
|
||||||
|
|
||||||
def confirm(self, tx_id: str, amount: str):
|
def confirm(self, tx_id: str, amount: str):
|
||||||
self.status = ContributionStatus.CONFIRMED
|
self.status = ContributionStatus.CONFIRMED
|
||||||
self.tx_id = tx_id
|
self.tx_id = tx_id
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import abc
|
import abc
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from grant.proposal.models import db, ma, Proposal
|
from grant.proposal.models import db, ma, Proposal, ProposalContribution, proposal_contributions_schema
|
||||||
from .enums import ProposalStatus, ProposalStage, Category
|
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus
|
||||||
|
|
||||||
|
|
||||||
def extract_filters(sw, strings):
|
def extract_filters(sw, strings):
|
||||||
|
@ -106,7 +106,63 @@ class ProposalPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ContributionPagination(Pagination):
|
||||||
|
def __init__(self):
|
||||||
|
self.FILTERS = [f'STATUS_{s}' for s in ContributionStatus.list()]
|
||||||
|
self.PAGE_SIZE = 9
|
||||||
|
self.SORT_MAP = {
|
||||||
|
'CREATED:DESC': ProposalContribution.date_created.desc(),
|
||||||
|
'CREATED:ASC': ProposalContribution.date_created,
|
||||||
|
'AMOUNT:DESC': ProposalContribution.amount.desc(),
|
||||||
|
'AMOUNT:ASC': ProposalContribution.amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
def paginate(
|
||||||
|
self,
|
||||||
|
schema: ma.Schema=proposal_contributions_schema,
|
||||||
|
query: db.Query=None,
|
||||||
|
page: int=1,
|
||||||
|
filters: list=None,
|
||||||
|
search: str=None,
|
||||||
|
sort: str='PUBLISHED:DESC',
|
||||||
|
):
|
||||||
|
query = query or ProposalContribution.query
|
||||||
|
sort = sort or 'CREATED:DESC'
|
||||||
|
|
||||||
|
# FILTER
|
||||||
|
if filters:
|
||||||
|
self.validate_filters(filters)
|
||||||
|
status_filters = extract_filters('STATUS_', filters)
|
||||||
|
|
||||||
|
if status_filters:
|
||||||
|
query = query.filter(ProposalContribution.status.in_(status_filters))
|
||||||
|
|
||||||
|
# SORT (see self.SORT_MAP)
|
||||||
|
if sort:
|
||||||
|
self.validate_sort(sort)
|
||||||
|
query = query.order_by(self.SORT_MAP[sort])
|
||||||
|
|
||||||
|
# SEARCH can match txids or amounts
|
||||||
|
if search:
|
||||||
|
query = query.filter(or_(
|
||||||
|
ProposalContribution.amount.ilike(f'%{search}%'),
|
||||||
|
ProposalContribution.tx_id.ilike(f'%{search}%'),
|
||||||
|
))
|
||||||
|
|
||||||
|
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||||
|
return {
|
||||||
|
'page': res.page,
|
||||||
|
'total': res.total,
|
||||||
|
'page_size': self.PAGE_SIZE,
|
||||||
|
'items': schema.dump(res.items),
|
||||||
|
'filters': filters,
|
||||||
|
'search': search,
|
||||||
|
'sort': sort
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# expose pagination methods here
|
# expose pagination methods here
|
||||||
proposal = ProposalPagination().paginate
|
proposal = ProposalPagination().paginate
|
||||||
|
contribution = ContributionPagination().paginate
|
||||||
# comment = CommentPagination().paginate
|
# comment = CommentPagination().paginate
|
||||||
# user = UserPagination().paginate
|
# user = UserPagination().paginate
|
||||||
|
|
Loading…
Reference in New Issue