2019-01-09 10:23:08 -08:00
|
|
|
import React from 'react';
|
|
|
|
import { view } from 'react-easy-state';
|
|
|
|
import { RouteComponentProps, withRouter } from 'react-router';
|
2019-01-29 15:50:27 -08:00
|
|
|
import {
|
|
|
|
Row,
|
|
|
|
Col,
|
|
|
|
Card,
|
|
|
|
Alert,
|
|
|
|
Button,
|
|
|
|
Collapse,
|
|
|
|
Popconfirm,
|
|
|
|
Modal,
|
|
|
|
Input,
|
|
|
|
Switch,
|
|
|
|
} from 'antd';
|
2019-01-09 10:23:08 -08:00
|
|
|
import TextArea from 'antd/lib/input/TextArea';
|
|
|
|
import store from 'src/store';
|
|
|
|
import { formatDateSeconds } from 'util/time';
|
2019-02-09 19:00:49 -08:00
|
|
|
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS } from 'src/types';
|
2019-01-09 10:23:08 -08:00
|
|
|
import { Link } from 'react-router-dom';
|
2019-01-16 21:01:29 -08:00
|
|
|
import Back from 'components/Back';
|
2019-01-29 15:50:27 -08:00
|
|
|
import Info from 'components/Info';
|
2019-01-09 10:23:08 -08:00
|
|
|
import Markdown from 'components/Markdown';
|
2019-02-06 10:38:07 -08:00
|
|
|
import ArbiterControl from 'components/ArbiterControl';
|
2019-01-16 21:01:29 -08:00
|
|
|
import './index.less';
|
2019-01-09 10:23:08 -08:00
|
|
|
|
|
|
|
type Props = RouteComponentProps<any>;
|
|
|
|
|
|
|
|
const STATE = {
|
|
|
|
showRejectModal: false,
|
|
|
|
rejectReason: '',
|
|
|
|
};
|
|
|
|
|
|
|
|
type State = typeof STATE;
|
|
|
|
|
|
|
|
class ProposalDetailNaked extends React.Component<Props, State> {
|
|
|
|
state = STATE;
|
|
|
|
rejectInput: null | TextArea = null;
|
|
|
|
componentDidMount() {
|
|
|
|
this.loadDetail();
|
|
|
|
}
|
|
|
|
render() {
|
|
|
|
const id = this.getIdFromQuery();
|
|
|
|
const { proposalDetail: p, proposalDetailFetching, proposalDetailApproving } = store;
|
|
|
|
const { rejectReason, showRejectModal } = this.state;
|
|
|
|
|
|
|
|
if (!p || (p && p.proposalId !== id) || proposalDetailFetching) {
|
|
|
|
return 'loading proposal...';
|
|
|
|
}
|
|
|
|
|
2019-02-06 10:38:07 -08:00
|
|
|
const renderDeleteControl = () => (
|
2019-01-09 10:23:08 -08:00
|
|
|
<Popconfirm
|
|
|
|
onConfirm={this.handleDelete}
|
|
|
|
title="Delete proposal?"
|
|
|
|
okText="delete"
|
|
|
|
cancelText="cancel"
|
|
|
|
>
|
2019-01-29 15:50:27 -08:00
|
|
|
<Button icon="delete" className="ProposalDetail-controls-control" block>
|
2019-01-09 10:23:08 -08:00
|
|
|
Delete
|
|
|
|
</Button>
|
|
|
|
</Popconfirm>
|
|
|
|
);
|
|
|
|
|
2019-02-06 10:38:07 -08:00
|
|
|
const renderArbiterControl = () => (
|
|
|
|
<ArbiterControl
|
|
|
|
{...p}
|
|
|
|
buttonProps={{
|
|
|
|
type: 'default',
|
|
|
|
className: 'ProposalDetail-controls-control',
|
|
|
|
block: true,
|
2019-02-09 19:18:26 -08:00
|
|
|
disabled: p.status !== PROPOSAL_STATUS.LIVE
|
2019-02-06 10:38:07 -08:00
|
|
|
}}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
|
|
|
const renderMatchingControl = () => (
|
2019-01-29 15:50:27 -08:00
|
|
|
<div className="ProposalDetail-controls-control">
|
|
|
|
<Popconfirm
|
|
|
|
overlayClassName="ProposalDetail-popover-overlay"
|
|
|
|
onConfirm={this.handleToggleMatching}
|
|
|
|
title={
|
|
|
|
<>
|
|
|
|
<div>
|
|
|
|
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
|
|
|
|
</div>
|
|
|
|
{p.status === PROPOSAL_STATUS.LIVE && (
|
|
|
|
<div>
|
|
|
|
This is a LIVE proposal, this will alter the funding state of the
|
|
|
|
proposal!
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
}
|
|
|
|
okText="ok"
|
|
|
|
cancelText="cancel"
|
|
|
|
>
|
|
|
|
<Switch checked={p.contributionMatching === 1} loading={false} />{' '}
|
|
|
|
</Popconfirm>
|
|
|
|
<span>
|
|
|
|
matching{' '}
|
|
|
|
<Info
|
|
|
|
placement="right"
|
|
|
|
content={
|
|
|
|
<span>
|
|
|
|
<b>Contribution matching</b>
|
|
|
|
<br /> Funded amount will be multiplied by 2.
|
|
|
|
</span>
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
const renderApproved = () =>
|
|
|
|
p.status === PROPOSAL_STATUS.APPROVED && (
|
|
|
|
<Alert
|
|
|
|
showIcon
|
|
|
|
type="success"
|
|
|
|
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
|
|
|
|
description={`
|
|
|
|
This proposal has been approved and will become live when a team-member
|
|
|
|
publishes it.
|
|
|
|
`}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
|
|
|
const rejectModal = (
|
|
|
|
<Modal
|
|
|
|
visible={showRejectModal}
|
|
|
|
title="Reject this proposal"
|
|
|
|
onOk={this.handleReject}
|
|
|
|
onCancel={() => this.setState({ showRejectModal: false })}
|
|
|
|
okButtonProps={{
|
|
|
|
disabled: rejectReason.length === 0,
|
|
|
|
loading: proposalDetailApproving,
|
|
|
|
}}
|
|
|
|
cancelButtonProps={{
|
|
|
|
loading: proposalDetailApproving,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Please provide a reason ({!!rejectReason.length && `${rejectReason.length}/`}
|
|
|
|
250 chars max):
|
|
|
|
<Input.TextArea
|
|
|
|
ref={ta => (this.rejectInput = ta)}
|
|
|
|
rows={4}
|
|
|
|
maxLength={250}
|
|
|
|
required={true}
|
|
|
|
value={rejectReason}
|
|
|
|
onChange={e => {
|
|
|
|
this.setState({ rejectReason: e.target.value });
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Modal>
|
|
|
|
);
|
|
|
|
|
|
|
|
const renderReview = () =>
|
|
|
|
p.status === PROPOSAL_STATUS.PENDING && (
|
|
|
|
<Alert
|
|
|
|
showIcon
|
|
|
|
type="warning"
|
|
|
|
message="Review Pending"
|
|
|
|
description={
|
|
|
|
<div>
|
|
|
|
<p>Please review this proposal and render your judgment.</p>
|
|
|
|
<Button
|
|
|
|
loading={store.proposalDetailApproving}
|
|
|
|
icon="check"
|
|
|
|
type="primary"
|
|
|
|
onClick={this.handleApprove}
|
|
|
|
>
|
|
|
|
Approve
|
|
|
|
</Button>
|
|
|
|
<Button
|
|
|
|
loading={store.proposalDetailApproving}
|
|
|
|
icon="close"
|
|
|
|
type="danger"
|
|
|
|
onClick={() => {
|
|
|
|
this.setState({ showRejectModal: true });
|
|
|
|
// hacky way of waiting for modal to render in before focus
|
|
|
|
setTimeout(() => {
|
|
|
|
if (this.rejectInput) this.rejectInput.focus();
|
|
|
|
}, 200);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Reject
|
|
|
|
</Button>
|
|
|
|
{rejectModal}
|
|
|
|
</div>
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
|
|
|
const renderRejected = () =>
|
|
|
|
p.status === PROPOSAL_STATUS.REJECTED && (
|
|
|
|
<Alert
|
|
|
|
showIcon
|
|
|
|
type="error"
|
|
|
|
message="Rejected"
|
|
|
|
description={
|
|
|
|
<div>
|
|
|
|
<p>
|
|
|
|
This proposal has been rejected. The team will be able to re-submit it for
|
|
|
|
approval should they desire to do so.
|
|
|
|
</p>
|
|
|
|
<b>Reason:</b>
|
|
|
|
<br />
|
|
|
|
<i>{p.rejectReason}</i>
|
|
|
|
</div>
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
2019-02-09 19:00:49 -08:00
|
|
|
const renderNominateArbiter = () =>
|
|
|
|
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
2019-02-05 12:45:26 -08:00
|
|
|
p.status === PROPOSAL_STATUS.LIVE && (
|
|
|
|
<Alert
|
|
|
|
showIcon
|
|
|
|
type="warning"
|
2019-02-09 19:00:49 -08:00
|
|
|
message="No arbiter on live proposal"
|
2019-02-05 12:45:26 -08:00
|
|
|
description={
|
|
|
|
<div>
|
|
|
|
<p>An arbiter is required to review milestone payout requests.</p>
|
2019-02-06 10:38:07 -08:00
|
|
|
<ArbiterControl {...p} />
|
2019-02-05 12:45:26 -08:00
|
|
|
</div>
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
2019-02-09 19:00:49 -08:00
|
|
|
const renderNominatedArbiter = () =>
|
|
|
|
PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status &&
|
|
|
|
p.status === PROPOSAL_STATUS.LIVE && (
|
|
|
|
<Alert
|
|
|
|
showIcon
|
|
|
|
type="info"
|
|
|
|
message="Arbiter has been nominated"
|
|
|
|
description={
|
|
|
|
<div>
|
|
|
|
<p>
|
|
|
|
<b>{p.arbiter.user!.displayName}</b> has been nominated for arbiter of
|
|
|
|
this proposal but has not yet accepted.
|
|
|
|
</p>
|
|
|
|
<ArbiterControl {...p} />
|
|
|
|
</div>
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
const renderDeetItem = (name: string, val: any) => (
|
|
|
|
<div className="ProposalDetail-deet">
|
|
|
|
<span>{name}</span>
|
2019-01-29 15:50:27 -08:00
|
|
|
{val}
|
2019-01-09 10:23:08 -08:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="ProposalDetail">
|
2019-01-16 21:01:29 -08:00
|
|
|
<Back to="/proposals" text="Proposals" />
|
2019-01-09 10:23:08 -08:00
|
|
|
<h1>{p.title}</h1>
|
|
|
|
<Row gutter={16}>
|
|
|
|
{/* MAIN */}
|
|
|
|
<Col span={18}>
|
|
|
|
{renderApproved()}
|
|
|
|
{renderReview()}
|
|
|
|
{renderRejected()}
|
2019-02-09 19:00:49 -08:00
|
|
|
{renderNominateArbiter()}
|
|
|
|
{renderNominatedArbiter()}
|
2019-01-09 10:23:08 -08:00
|
|
|
<Collapse defaultActiveKey={['brief', 'content']}>
|
|
|
|
<Collapse.Panel key="brief" header="brief">
|
|
|
|
{p.brief}
|
|
|
|
</Collapse.Panel>
|
|
|
|
|
|
|
|
<Collapse.Panel key="content" header="content">
|
|
|
|
<Markdown source={p.content} />
|
|
|
|
</Collapse.Panel>
|
|
|
|
|
|
|
|
{/* TODO - comments, milestones, updates &etc. */}
|
|
|
|
<Collapse.Panel key="json" header="json">
|
|
|
|
<pre>{JSON.stringify(p, null, 4)}</pre>
|
|
|
|
</Collapse.Panel>
|
|
|
|
</Collapse>
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
{/* RIGHT SIDE */}
|
|
|
|
<Col span={6}>
|
|
|
|
{/* ACTIONS */}
|
2019-01-29 15:50:27 -08:00
|
|
|
<Card size="small" className="ProposalDetail-controls">
|
2019-02-06 10:38:07 -08:00
|
|
|
{renderDeleteControl()}
|
|
|
|
{renderArbiterControl()}
|
|
|
|
{renderMatchingControl()}
|
2019-01-09 10:23:08 -08:00
|
|
|
{/* TODO - other actions */}
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
{/* DETAILS */}
|
2019-02-01 11:13:30 -08:00
|
|
|
<Card title="Details" size="small">
|
2019-01-09 10:23:08 -08:00
|
|
|
{renderDeetItem('id', p.proposalId)}
|
|
|
|
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
|
|
|
{renderDeetItem('status', p.status)}
|
|
|
|
{renderDeetItem('category', p.category)}
|
|
|
|
{renderDeetItem('target', p.target)}
|
2019-01-29 15:50:27 -08:00
|
|
|
{renderDeetItem('contributed', p.contributed)}
|
|
|
|
{renderDeetItem('funded (inc. matching)', p.funded)}
|
|
|
|
{renderDeetItem('matching', p.contributionMatching)}
|
2019-02-09 19:00:49 -08:00
|
|
|
|
|
|
|
{renderDeetItem(
|
|
|
|
'arbiter',
|
|
|
|
<>
|
|
|
|
{p.arbiter.user && (
|
|
|
|
<Link to={`/users/${p.arbiter.user.userid}`}>
|
|
|
|
{p.arbiter.user.displayName}
|
|
|
|
</Link>
|
|
|
|
)}
|
|
|
|
({p.arbiter.status})
|
|
|
|
</>,
|
|
|
|
)}
|
2019-02-01 11:13:30 -08:00
|
|
|
{p.rfp &&
|
2019-02-05 12:45:26 -08:00
|
|
|
renderDeetItem(
|
|
|
|
'rfp',
|
|
|
|
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
|
|
|
|
)}
|
2019-01-09 10:23:08 -08:00
|
|
|
</Card>
|
|
|
|
|
|
|
|
{/* TEAM */}
|
2019-02-01 11:13:30 -08:00
|
|
|
<Card title="Team" size="small">
|
2019-01-09 10:23:08 -08:00
|
|
|
{p.team.map(t => (
|
2019-01-16 21:01:29 -08:00
|
|
|
<div key={t.userid}>
|
|
|
|
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
|
|
|
</div>
|
2019-01-09 10:23:08 -08:00
|
|
|
))}
|
|
|
|
</Card>
|
2019-02-01 11:13:30 -08:00
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
{/* TODO: contributors here? */}
|
|
|
|
</Col>
|
|
|
|
</Row>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
private getIdFromQuery = () => {
|
|
|
|
return Number(this.props.match.params.id);
|
|
|
|
};
|
|
|
|
|
|
|
|
private loadDetail = () => {
|
|
|
|
store.fetchProposalDetail(this.getIdFromQuery());
|
|
|
|
};
|
|
|
|
|
|
|
|
private handleDelete = () => {
|
|
|
|
if (!store.proposalDetail) return;
|
|
|
|
store.deleteProposal(store.proposalDetail.proposalId);
|
|
|
|
};
|
|
|
|
|
|
|
|
private handleApprove = () => {
|
|
|
|
store.approveProposal(true);
|
|
|
|
};
|
|
|
|
|
|
|
|
private handleReject = async () => {
|
|
|
|
await store.approveProposal(false, this.state.rejectReason);
|
|
|
|
this.setState({ showRejectModal: false });
|
|
|
|
};
|
2019-01-29 15:50:27 -08:00
|
|
|
|
|
|
|
private handleToggleMatching = async () => {
|
|
|
|
if (store.proposalDetail) {
|
|
|
|
// we lock this to be 1 or 0 for now, we may support more values later on
|
|
|
|
const contributionMatching =
|
|
|
|
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
|
|
|
|
store.updateProposalDetail({ contributionMatching });
|
|
|
|
}
|
|
|
|
};
|
2019-01-09 10:23:08 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
|
|
|
export default ProposalDetail;
|