full payout flow operational

This commit is contained in:
Aaron 2019-02-13 10:54:46 -06:00
parent c47c69ea3c
commit fd9a4c5393
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
22 changed files with 552 additions and 102 deletions

View File

@ -16,6 +16,7 @@ class Home extends React.Component {
proposalCount,
proposalPendingCount,
proposalNoArbiterCount,
proposalMilestonePayoutsCount,
} = store.stats;
const actionItems = [
@ -36,6 +37,14 @@ class Home extends React.Component {
to view them.
</div>
),
!!proposalMilestonePayoutsCount && (
<div>
<Icon type="exclamation-circle" /> There are{' '}
<b>{proposalMilestonePayoutsCount}</b> proposals <b>with approved payouts</b>.{' '}
<Link to="/proposals?filters[]=MILESTONE_ACCEPTED">Click here</Link> to view
them.
</div>
),
].filter(Boolean);
return (

View File

@ -37,4 +37,13 @@
max-width: 400px;
}
}
&-alert {
& pre {
margin: 1rem 0;
overflow: hidden;
word-break: break-all;
white-space: inherit;
}
}
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import BN from 'bn.js';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import {
@ -12,23 +13,26 @@ import {
Modal,
Input,
Switch,
message,
} from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS } from 'src/types';
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import './index.less';
import { toZat, fromZat } from 'src/util/units';
type Props = RouteComponentProps<any>;
const STATE = {
showRejectModal: false,
rejectReason: '',
paidTxId: '',
};
type State = typeof STATE;
@ -68,7 +72,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
disabled: p.status !== PROPOSAL_STATUS.LIVE
disabled: p.status !== PROPOSAL_STATUS.LIVE,
}}
/>
);
@ -245,6 +249,54 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderMilestoneAccepted = () => {
if (
!(
p.status === PROPOSAL_STATUS.LIVE &&
p.currentMilestone &&
p.currentMilestone.stage === MILESTONE_STAGE.ACCEPTED
)
) {
return;
}
const ms = p.currentMilestone;
const amount = fromZat(
toZat(p.target)
.mul(new BN(ms.payoutPercent))
.divn(100),
);
return (
<Alert
className="ProposalDetail-alert"
showIcon
type="warning"
message={null}
description={
<div>
<p>
<b>
Milestone {ms.index + 1} - {ms.title}
</b>{' '}
was accepted on {formatDateSeconds(ms.dateAccepted)}.
</p>
<p>
{' '}
Please make a payment of <b>{amount.toString()} ZEC</b> to:
</p>{' '}
<pre>{p.payoutAddress}</pre>
<Input.Search
placeholder="please enter payment txid"
value={this.state.paidTxId}
enterButton="Mark Paid"
onChange={e => this.setState({ paidTxId: e.target.value })}
onSearch={this.handlePaidMilestone}
/>
</div>
}
/>
);
};
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
@ -264,6 +316,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderRejected()}
{renderNominateArbiter()}
{renderNominatedArbiter()}
{renderMilestoneAccepted()}
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
@ -295,12 +348,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('id', p.proposalId)}
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
{renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)}
{renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)}
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
{renderDeetItem(
'arbiter',
<>
@ -365,6 +418,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
store.updateProposalDetail({ contributionMatching });
}
};
private handlePaidMilestone = async () => {
const pid = store.proposalDetail!.proposalId;
const mid = store.proposalDetail!.currentMilestone!.id;
await store.markMilestonePaid(pid, mid, this.state.paidTxId);
message.success('Marked milestone paid.');
};
}
const ProposalDetail = withRouter(view(ProposalDetailNaked));

View File

@ -95,6 +95,14 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
return data;
}
async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
const { data } = await api.put(
`/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`,
{ txId },
);
return data;
}
async function getEmailExample(type: string) {
const { data } = await api.get(`/admin/email/example/${type}`);
return data;
@ -154,6 +162,7 @@ const app = store({
proposalCount: 0,
proposalPendingCount: 0,
proposalNoArbiterCount: 0,
proposalMilestonePayoutsCount: 0,
},
usersFetching: false,
@ -178,6 +187,7 @@ const app = store({
proposalDetail: null as null | Proposal,
proposalDetailFetching: false,
proposalDetailApproving: false,
proposalDetailMarkingMilestonePaid: false,
rfps: [] as RFP[],
rfpsFetching: false,
@ -424,6 +434,17 @@ const app = store({
app.proposalDetailApproving = false;
},
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
app.proposalDetailMarkingMilestonePaid = true;
try {
const res = await markMilestonePaid(proposalId, milestoneId, txId);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailMarkingMilestonePaid = false;
},
// Email
async getEmailExample(type: string) {

View File

@ -4,10 +4,24 @@ export interface SocialMedia {
service: string;
username: string;
}
// NOTE: sync with backend/grant/utils/enums.py MilestoneStage
export enum MILESTONE_STAGE {
IDLE = 'IDLE',
REQUESTED = 'REQUESTED',
REJECTED = 'REJECTED',
ACCEPTED = 'ACCEPTED',
PAID = 'PAID',
}
export interface Milestone {
id: number;
index: number;
content: string;
dateCreated: string;
dateEstimated: string;
dateCreated: number;
dateEstimated: number;
dateRequested: number;
dateAccepted: number;
dateRejected: number;
datePaid: number;
immediatePayout: boolean;
payoutPercent: string;
stage: string;
@ -61,7 +75,7 @@ export interface Proposal {
proposalId: number;
brief: string;
status: PROPOSAL_STATUS;
proposalAddress: string;
payoutAddress: string;
dateCreated: number;
dateApproved: number;
datePublished: number;
@ -70,6 +84,7 @@ export interface Proposal {
stage: string;
category: string;
milestones: Milestone[];
currentMilestone?: Milestone;
team: User[];
comments: Comment[];
contractStatus: string;

View File

@ -3,6 +3,7 @@ import {
RFP_STATUSES,
CONTRIBUTION_STATUSES,
PROPOSAL_ARBITER_STATUSES,
MILESTONE_STAGES,
} from './statuses';
export interface Filter {
@ -41,6 +42,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
color: s.tagColor,
group: 'Arbiter',
})),
)
.concat(
MILESTONE_STAGES.map(s => ({
id: `MILESTONE_${s.id}`,
display: `Milestone: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Milestone',
})),
);
export const proposalFilters: Filters = {

View File

@ -3,6 +3,7 @@ import {
RFP_STATUS,
CONTRIBUTION_STATUS,
PROPOSAL_ARBITER_STATUS,
MILESTONE_STAGE,
} from 'src/types';
export interface StatusSoT<E> {
@ -12,6 +13,39 @@ export interface StatusSoT<E> {
hint: string;
}
export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
{
id: MILESTONE_STAGE.IDLE,
tagDisplay: 'Idle',
tagColor: '#e9c510',
hint: 'Proposal has has an idle milestone.',
},
{
id: MILESTONE_STAGE.REQUESTED,
tagDisplay: 'Requested',
tagColor: '#e9c510',
hint: 'Proposal has has a milestone with a requested payout.',
},
{
id: MILESTONE_STAGE.REJECTED,
tagDisplay: 'Rejected',
tagColor: '#e9c510',
hint: 'Proposal has has a milestone with a rejected payout.',
},
{
id: MILESTONE_STAGE.ACCEPTED,
tagDisplay: 'Accepted',
tagColor: '#e9c510',
hint: 'Proposal has an accepted milestone, and awaits payment.',
},
{
id: MILESTONE_STAGE.PAID,
tagDisplay: 'Paid',
tagColor: '#e9c510',
hint: 'Proposal has a paid milestone.',
},
];
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
{
id: PROPOSAL_STATUS.APPROVED,

63
admin/src/util/units.ts Normal file
View File

@ -0,0 +1,63 @@
// From https://github.com/MyCryptoHQ/MyCrypto/blob/develop/common/libs/units.ts
import BN from 'bn.js';
export const ZCASH_DECIMAL = 8;
export const Units = {
zat: '1',
zcash: '100000000',
};
export type Zat = BN;
export type UnitKey = keyof typeof Units;
export const handleValues = (input: string | BN) => {
if (typeof input === 'string') {
return new BN(input);
}
if (typeof input === 'number') {
return new BN(input);
}
if (BN.isBN(input)) {
return input;
} else {
throw Error('unsupported value conversion');
}
};
export const Zat = (input: string | BN): Zat => handleValues(input);
const stripRightZeros = (str: string) => {
const strippedStr = str.replace(/0+$/, '');
return strippedStr === '' ? null : strippedStr;
};
export const baseToConvertedUnit = (value: string, decimal: number) => {
if (decimal === 0) {
return value;
}
const paddedValue = value.padStart(decimal + 1, '0'); // 0.1 ==>
const integerPart = paddedValue.slice(0, -decimal);
const fractionPart = stripRightZeros(paddedValue.slice(-decimal));
return fractionPart ? `${integerPart}.${fractionPart}` : `${integerPart}`;
};
const convertedToBaseUnit = (value: string, decimal: number) => {
if (decimal === 0) {
return value;
}
const [integerPart, fractionPart = ''] = value.split('.');
const paddedFraction = fractionPart.padEnd(decimal, '0');
return `${integerPart}${paddedFraction}`;
};
export const fromZat = (zat: Zat) => {
return baseToConvertedUnit(zat.toString(), ZCASH_DECIMAL);
};
export const toZat = (value: string | number): Zat => {
value = value.toString();
const zat = convertedToBaseUnit(value, ZCASH_DECIMAL);
return Zat(zat);
};
export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1;

View File

@ -1,4 +1,4 @@
from flask import Blueprint, request
from functools import reduce
from flask import Blueprint, request
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
@ -14,11 +14,12 @@ from grant.proposal.models import (
proposal_contribution_schema,
user_proposal_contributions_schema,
)
from grant.milestone.models import Milestone
from grant.user.models import User, admin_users_schema, admin_user_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.misc import make_url
from grant.utils.enums import ProposalStatus, ContributionStatus, ProposalArbiterStatus
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage
from grant.utils import pagination
from sqlalchemy import func, or_
@ -66,11 +67,17 @@ def stats():
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
.scalar()
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
.join(Proposal.milestones) \
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(Milestone.stage == MilestoneStage.ACCEPTED) \
.scalar()
return {
"userCount": user_count,
"proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
"proposalNoArbiterCount": proposal_no_arbiter_count,
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
}
@ -253,7 +260,35 @@ def approve_proposal(id, is_approve, reject_reason=None):
db.session.commit()
return proposal_schema.dump(proposal)
return {"message": "Not implemented."}, 400
return {"message": "No proposal found."}, 404
@blueprint.route("/proposals/<id>/milestone/<mid>/paid", methods=["PUT"])
@endpoint.api(
parameter('txId', type=str, required=True),
)
@admin_auth_required
def paid_milestone_payout_request(id, mid, tx_id):
proposal = Proposal.query.filter_by(id=id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
if not proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
for ms in proposal.milestones:
if ms.id == int(mid):
ms.mark_paid(tx_id)
# TODO: email TEAM that payout request was PAID
db.session.add(ms)
db.session.flush()
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
if num_paid == len(proposal.milestones):
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
db.session.add(proposal)
db.session.flush()
db.session.commit()
return proposal_schema.dump(proposal), 200
return {"message": "No milestone matching id"}, 404
# EMAIL

View File

@ -85,7 +85,7 @@ class Milestone(db.Model):
def accept_request(self, arbiter_id: int):
if self.stage != MilestoneStage.REQUESTED:
raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage')
self.stage = MilestoneStage.PAID
self.stage = MilestoneStage.ACCEPTED
self.date_accepted = datetime.datetime.now()
self.accept_arbiter_id = arbiter_id

View File

@ -221,7 +221,8 @@ class Proposal(db.Model):
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan")
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
milestones = db.relationship("Milestone", backref="proposal", order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
milestones = db.relationship("Milestone", backref="proposal",
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
@ -231,7 +232,7 @@ class Proposal(db.Model):
title: str = '',
brief: str = '',
content: str = '',
stage: str = '',
stage: str = ProposalStage.PREVIEW,
target: str = '0',
payout_address: str = '',
deadline_duration: int = 5184000, # 60 days
@ -436,6 +437,7 @@ class Proposal(db.Model):
for ms in self.milestones:
if ms.stage != MilestoneStage.PAID:
return ms
return self.milestones[-1] # return last one if all PAID
return None

View File

@ -482,14 +482,14 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
contribution.confirm(tx_id=txid, amount=zec_amount)
db.session.add(contribution)
db.session.commit()
db.session.flush()
if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal)
db.session.commit()
db.session.flush()
# email progress of staking, partial or complete
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
@ -523,8 +523,11 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
# on funding target reached.
if contribution.proposal.status == ProposalStatus.LIVE:
if contribution.proposal.is_funded:
contribution.proposal.stage = ProposalStage.IN_PROGRESS
contribution.proposal.stage = ProposalStage.WIP
db.session.add(contribution.proposal)
db.session.flush()
db.session.commit()
return None, 200
@ -549,20 +552,22 @@ def delete_proposal_contribution(contribution_id):
return None, 202
# TODO
# request MS payout
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/request", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def request_milestone_payout(proposal_id, milestone_id):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id) :
if ms.id == int(milestone_id):
ms.request_payout(g.current_user.id)
# TODO: email ARBITER to review payout request
db.session.add(ms)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
return {"message": "No milestone matching id"}, 404
# accept MS payout (arbiter)
@ -570,14 +575,17 @@ def request_milestone_payout(proposal_id, milestone_id):
@requires_arbiter_auth
@endpoint.api()
def accept_milestone_payout_request(proposal_id, milestone_id):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id) :
if ms.id == int(milestone_id):
ms.accept_request(g.current_user.id)
# TODO: email TEAM that payout request accepted (maybe, or wait until paid?)
db.session.add(ms)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
return {"message": "No milestone matching id"}, 404
# reject MS payout (arbiter) (reason)
@ -587,13 +595,14 @@ def accept_milestone_payout_request(proposal_id, milestone_id):
parameter('reason', type=str, required=True),
)
def reject_milestone_payout_request(proposal_id, milestone_id, reason):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id) :
if ms.id == int(milestone_id):
ms.reject_request(g.current_user.id, reason)
# TODO: email TEAM that payout request was rejected
db.session.add(ms)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
# (ADMIN) MS payout (txid)
return {"message": "No milestone matching id"}, 404

View File

@ -33,8 +33,9 @@ ProposalSort = ProposalSortEnum()
class ProposalStageEnum(CustomEnum):
PREVIEW = 'PREVIEW'
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
IN_PROGRESS = 'IN_PROGRESS'
WIP = 'WIP'
COMPLETED = 'COMPLETED'

View File

@ -2,7 +2,8 @@ import abc
from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
from grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
def extract_filters(sw, strings):
@ -50,6 +51,7 @@ class ProposalPagination(Pagination):
self.FILTERS.extend([f'STAGE_{s}' for s in ProposalStage.list()])
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': Proposal.date_created.desc(),
@ -77,6 +79,7 @@ class ProposalPagination(Pagination):
stage_filters = extract_filters('STAGE_', filters)
cat_filters = extract_filters('CAT_', filters)
arbiter_filters = extract_filters('ARBITER_', filters)
milestone_filters = extract_filters('MILESTONE_', filters)
if status_filters:
query = query.filter(Proposal.status.in_(status_filters))
@ -89,6 +92,9 @@ class ProposalPagination(Pagination):
if arbiter_filters:
query = query.join(Proposal.arbiter) \
.filter(ProposalArbiter.status.in_(arbiter_filters))
if milestone_filters:
query = query.join(Proposal.milestones) \
.filter(Milestone.stage.in_(milestone_filters))
# SORT (see self.SORT_MAP)
if sort:

View File

@ -57,6 +57,7 @@ export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = {
};
export enum PROPOSAL_STAGE {
PREVIEW = 'PREVIEW',
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
WIP = 'WIP',
COMPLETED = 'COMPLETED',
@ -68,6 +69,10 @@ interface StageUI {
}
export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
PREVIEW: {
label: 'Preview',
color: '#8e44ad',
},
FUNDING_REQUIRED: {
label: 'Funding required',
color: '#8e44ad',

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS } from 'types';
import moment from 'moment';
import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'types';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import { updateUserArbiter } from 'api/api';
@ -27,15 +28,32 @@ type Props = OwnProps & StateProps & DispatchProps;
class ProfileArbitrated extends React.Component<Props, {}> {
render() {
const { status } = this.props.arbiter;
const { title, proposalId } = this.props.arbiter.proposal;
const { title, proposalId, currentMilestone } = this.props.arbiter.proposal;
const isMsPayoutReq =
currentMilestone && currentMilestone.stage === MILESTONE_STAGE.REQUESTED;
const msTitle = currentMilestone && currentMilestone.title;
const info = {
[PAS.MISSING]: <>{/* nada */}</>,
[PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal.</>,
[PAS.ACCEPTED]: (
<>
As arbiter of this proposal, you are responsible for reviewing milestone payout
requests. You may{' '}
{isMsPayoutReq && (
<>
The team has requested payout for <b>{msTitle}</b>{' '}
{moment((currentMilestone!.dateRequested || 0) * 1000).fromNow()}. Please
click the button to proceed.
</>
)}
{!isMsPayoutReq && (
<>
As arbiter of this proposal, you are responsible for reviewing milestone
payout requests.{' '}
</>
)}
<br />
<br />
You may{' '}
<Popconfirm
title="Stop acting as arbiter?"
onConfirm={() => this.acceptArbiter(false)}
@ -57,7 +75,15 @@ class ProfileArbitrated extends React.Component<Props, {}> {
<Button onClick={() => this.acceptArbiter(false)}>Reject</Button>
</>
),
[PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}</>,
[PAS.ACCEPTED]: (
<>
{isMsPayoutReq && (
<Link to={`/proposals/${proposalId}?tab=milestones`}>
<Button type="primary">Review Milestone</Button>
</Link>
)}
</>
),
};
return (

View File

@ -102,6 +102,31 @@
flex: 1;
}
&-action {
q {
display: block;
margin-bottom: 0.5rem;
background: rgba(0, 0, 0, 0.06);
padding: 0.5rem;
}
h3 {
font-size: 1rem;
text-align: center;
}
&-controls {
display: flex;
& > * {
flex-grow: 1;
}
& > * + * {
margin-left: 0.5rem;
}
}
}
&-divider {
width: 1px;
background: rgba(0, 0, 0, 0.05);

View File

@ -1,19 +1,21 @@
import { throttle } from 'lodash';
import React, { ReactNode } from 'react';
import moment from 'moment';
import { Alert, Steps, Button, message } from 'antd';
import classnames from 'classnames';
import { connect } from 'react-redux';
import { Alert, Steps, Button, message, Modal, Input } from 'antd';
import { AlertProps } from 'antd/lib/alert';
import { StepProps } from 'antd/lib/steps';
import TextArea from 'antd/lib/input/TextArea';
import { Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types';
import { PROPOSAL_STAGE } from 'api/constants';
import UnitDisplay from 'components/UnitDisplay';
import Loader from 'components/Loader';
import { AppState } from 'store/reducers';
import { connect } from 'react-redux';
import classnames from 'classnames';
import Placeholder from 'components/Placeholder';
import { AlertProps } from 'antd/lib/alert';
import { StepProps } from 'antd/lib/steps';
import { proposalActions } from 'modules/proposals';
import './index.less';
import { ProposalDetail } from 'modules/proposals/reducers';
import './index.less';
enum STEP_STATUS {
WAIT = 'wait',
@ -31,7 +33,9 @@ const milestoneStageToStepState = {
} as { [key in MILESTONE_STAGE]: StepProps['status'] };
const fmtDate = (n: undefined | number) =>
(n && moment(n * 1000).format('MMM Do, YYYY')) || undefined;
(n && moment(n * 1000).format('MMM Do, YYYY, h:mm a')) || undefined;
const fmtDateFromNow = (n: undefined | number) => (n && moment(n * 1000).fromNow()) || '';
interface OwnProps {
proposal: ProposalDetail;
@ -49,29 +53,39 @@ interface State {
step: number;
activeMilestoneIdx: number;
doTitlesOverflow: boolean;
showRejectModal: boolean;
rejectReason: string;
rejectMilestoneId: number;
}
class ProposalMilestones extends React.Component<Props, State> {
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
ref: React.RefObject<HTMLDivElement>;
rejectInput: null | TextArea;
throttledUpdateDoTitlesOverflow: () => void;
constructor(props: Props) {
super(props);
this.rejectInput = null;
this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef());
this.ref = React.createRef();
this.throttledUpdateDoTitlesOverflow = throttle(this.updateDoTitlesOverflow, 500);
const step =
(this.props.proposal &&
this.props.proposal.currentMilestone &&
this.props.proposal.currentMilestone.index) ||
0;
this.state = {
step: 0,
step,
activeMilestoneIdx: 0,
doTitlesOverflow: true,
showRejectModal: false,
rejectReason: '',
rejectMilestoneId: -1,
};
}
componentDidMount() {
if (this.props.proposal) {
const { currentMilestone } = this.props.proposal;
this.setState({ step: (currentMilestone && currentMilestone.index) || 0 });
}
this.updateDoTitlesOverflow();
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
}
@ -89,27 +103,86 @@ class ProposalMilestones extends React.Component<Props, State> {
}
const {
requestPayoutError,
isRequestingPayout,
acceptPayoutError,
isAcceptingPayout,
rejectPayoutError,
isRejectingPayout,
} = this.props.proposal;
if (!prevProps.proposal.requestPayoutError && requestPayoutError) {
message.error(requestPayoutError);
}
if (
prevProps.proposal.isRequestingPayout &&
!isRequestingPayout &&
!requestPayoutError
) {
message.success('Payout requested.');
}
if (!prevProps.proposal.acceptPayoutError && acceptPayoutError) {
message.error(acceptPayoutError);
}
if (
prevProps.proposal.isAcceptingPayout &&
!isAcceptingPayout &&
!acceptPayoutError
) {
message.success('Payout approved.');
}
if (!prevProps.proposal.rejectPayoutError && rejectPayoutError) {
message.error(rejectPayoutError);
}
if (
prevProps.proposal.isRejectingPayout &&
!isRejectingPayout &&
!rejectPayoutError
) {
message.info('Payout rejected.');
}
}
render() {
const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props;
const { rejectReason, showRejectModal } = this.state;
if (!proposal) {
return <Loader />;
}
const { milestones, currentMilestone } = proposal;
const { milestones, currentMilestone, isRejectingPayout } = proposal;
const milestoneCount = milestones.length;
// arbiter reject modal
const rejectModal = (
<Modal
visible={showRejectModal}
title="Reject this milestone payout"
onOk={this.handleReject}
onCancel={() => this.setState({ showRejectModal: false })}
okButtonProps={{
disabled: rejectReason.length === 0,
loading: isRejectingPayout,
}}
cancelButtonProps={{
loading: isRejectingPayout,
}}
>
Please provide a reason:
<Input.TextArea
ref={ta => (this.rejectInput = ta)}
rows={4}
maxLength={250}
required={true}
value={rejectReason}
onChange={e => {
this.setState({ rejectReason: e.target.value });
}}
/>
</Modal>
);
// generate steps
const milestoneSteps = milestones.map((ms, i) => {
const status =
currentMilestone &&
@ -147,7 +220,11 @@ class ProposalMilestones extends React.Component<Props, State> {
))}
</Steps>
<Milestone
isFunded={[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(
proposal.stage,
)}
proposalId={proposal.proposalId}
showRejectPayout={this.handleShowRejectPayout}
{...{ requestPayout, acceptPayout, rejectPayout }}
{...activeMilestone}
isCurrent={activeIsCurrent}
@ -161,10 +238,28 @@ class ProposalMilestones extends React.Component<Props, State> {
subtitle="The creator of this proposal has not setup any milestones"
/>
)}
{rejectModal}
</div>
);
}
private handleShowRejectPayout = (milestoneId: number) => {
this.setState({ showRejectModal: true, rejectMilestoneId: milestoneId });
// try to focus on text-area after modal loads
setTimeout(() => {
if (this.rejectInput) this.rejectInput.focus();
}, 200);
};
private handleReject = () => {
const { proposalId } = this.props.proposal;
const { rejectMilestoneId, rejectReason } = this.state;
this.props.rejectPayout(proposalId, rejectMilestoneId, rejectReason);
this.setState({ showRejectModal: false, rejectMilestoneId: -1, rejectReason: '' });
};
private updateDoTitlesOverflow = () => {
// hmr can sometimes muck up refs, let's make sure they all exist
if (!this.ref || !this.ref.current || !this.stepTitleRefs) {
@ -203,10 +298,12 @@ class ProposalMilestones extends React.Component<Props, State> {
// Milestone
type MSProps = ProposalMilestone & DispatchProps;
interface MilestoneProps extends MSProps {
showRejectPayout: (milestoneId: number) => void;
isTeamMember: boolean;
isArbiter: boolean;
isCurrent: boolean;
proposalId: number;
isFunded: boolean;
}
const Milestone: React.SFC<MilestoneProps> = p => {
const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
@ -217,8 +314,8 @@ const Milestone: React.SFC<MilestoneProps> = p => {
type: 'info',
message: (
<>
The team has requested a payout for this milestone. It is currently under
review.
The team requested a payout for this milestone {fmtDateFromNow(p.dateRequested)}
. It is currently under review.
</>
),
}),
@ -226,7 +323,7 @@ const Milestone: React.SFC<MilestoneProps> = p => {
type: 'warning',
message: (
<span>
Payout for this milestone was rejected on {fmtDate(p.dateRejected)}.
Payout for this milestone was rejected {fmtDateFromNow(p.dateRejected)}.
{p.isTeamMember ? ' You ' : ' The team '} can request another review for payout
at any time.
</span>
@ -236,7 +333,7 @@ const Milestone: React.SFC<MilestoneProps> = p => {
type: 'info',
message: (
<span>
Payout for this milestone was accepted on {fmtDate(p.dateAccepted)}.
Payout for this milestone was accepted {fmtDateFromNow(p.dateAccepted)}.{' '}
<strong>{reward}</strong> will be sent to{' '}
{p.isTeamMember ? ' you ' : ' the team '} soon.
</span>
@ -247,8 +344,7 @@ const Milestone: React.SFC<MilestoneProps> = p => {
message: (
<span>
The team was awarded <strong>{reward}</strong>{' '}
{p.immediatePayout && ` as an initial payout `} on ${fmtDate(p.datePaid)}
`.
{p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}.
</span>
),
}),
@ -283,13 +379,14 @@ const Milestone: React.SFC<MilestoneProps> = p => {
};
const MilestoneAction: React.SFC<MilestoneProps> = p => {
if (!p.isCurrent) {
if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) {
return null;
}
const team = {
[MILESTONE_STAGE.IDLE]: () => (
<>
<h3>Payment Request</h3>
{p.immediatePayout && (
<p>
Congratulations on getting funded! You can now begin the process of receiving
@ -306,99 +403,123 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
)}
{!p.immediatePayout &&
p.index > 0 && <p>You can request a payment for this milestone.</p>}
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)}>
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)} block>
{(p.immediatePayout && 'Request initial payout') || 'Request payout'}
</Button>
</>
),
[MILESTONE_STAGE.REQUESTED]: () => (
<p>
The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be
notified when it has been reviewed.
</p>
<>
<h3>Payment Requested</h3>
<p>
The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be
notified when it has been reviewed.
</p>
</>
),
[MILESTONE_STAGE.REJECTED]: () => (
<>
<p>
The request for payout was rejected for the following reason:
<q>{p.rejectReason}</q>
You may request payout again when you are ready.
</p>
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)}>
<h3>Payment Rejected</h3>
<p>The request for payout was rejected for the following reason:</p>
<q>{p.rejectReason}</q>
<p>You may request payout again when you are ready.</p>
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)} block>
Request payout
</Button>
</>
),
[MILESTONE_STAGE.ACCEPTED]: () => (
<p>
Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly.
</p>
<>
<h3>Awaiting Payment</h3>
<p>
Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly.
</p>
</>
),
[MILESTONE_STAGE.PAID]: () => <></>,
} as { [key in MILESTONE_STAGE]: () => ReactNode };
const others = {
[MILESTONE_STAGE.IDLE]: () => (
<p>The team may request a payout for this milestone at any time.</p>
<>
<h3>Payment Request</h3>
<p>The team may request a payout for this milestone at any time.</p>
</>
),
[MILESTONE_STAGE.REQUESTED]: () => (
<p>
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval.
</p>
<>
<h3>Payment Requested</h3>
<p>
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval.
</p>
</>
),
[MILESTONE_STAGE.REJECTED]: () => (
<p>
The payout request was denied on {fmtDate(p.dateRejected)} for the following
reason:
<>
<h3>Payment Rejected</h3>
<p>
The payout request was denied on {fmtDate(p.dateRejected)} for the following
reason:
</p>
<q>{p.rejectReason}</q>
</p>
</>
),
[MILESTONE_STAGE.ACCEPTED]: () => (
<>The payout request was approved on {fmtDate(p.dateAccepted)}.</>
<>
<h3>Awaiting Payment</h3>
<p>The payout request was approved on {fmtDate(p.dateAccepted)}.</p>
</>
),
[MILESTONE_STAGE.PAID]: () => <></>,
} as { [key in MILESTONE_STAGE]: () => ReactNode };
const arbiter = {
[MILESTONE_STAGE.IDLE]: () => (
<p>
The team may request a payout for this milestone at any time. As arbiter you will
be responsible for reviewing these requests.
</p>
<>
<h3>Payment Request</h3>
<p>
The team may request a payout for this milestone at any time. As arbiter you
will be responsible for reviewing these requests.
</p>
</>
),
[MILESTONE_STAGE.REQUESTED]: () => (
<>
<h3>Payment Requested</h3>
<p>
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits your
approval.
</p>
<Button type="primary" onClick={() => p.acceptPayout(p.proposalId, p.id)}>
Accept
</Button>
<Button
type="danger"
onClick={() =>
p.rejectPayout(p.proposalId, p.id, 'Test reason. (TODO: modal w/ text input)')
}
>
Reject
</Button>
<div className="ProposalMilestones-milestone-action-controls">
<Button type="primary" onClick={() => p.acceptPayout(p.proposalId, p.id)}>
Accept
</Button>
<Button type="danger" onClick={() => p.showRejectPayout(p.id)}>
Reject
</Button>
</div>
</>
),
[MILESTONE_STAGE.REJECTED]: () => (
<p>
The payout request was denied on {fmtDate(p.dateRejected)} for the following
reason:
<>
<h3>Payment Rejected</h3>
<p>
You rejected this payment request on {fmtDate(p.dateRejected)} for the following
reason:
</p>
<q>{p.rejectReason}</q>
</p>
</>
),
[MILESTONE_STAGE.ACCEPTED]: () => (
<>The payout request was approved on {fmtDate(p.dateAccepted)}.</>
<>
<h3>Awaiting Payment</h3>
<p>You approved this payment request on {fmtDate(p.dateAccepted)}.</p>
</>
),
[MILESTONE_STAGE.PAID]: () => <></>,
} as { [key in MILESTONE_STAGE]: () => ReactNode };
let content: ReactNode = null;
let content = null;
if (p.isTeamMember) {
content = team[p.stage]();
} else if (p.isArbiter) {

View File

@ -9,7 +9,7 @@ import { User } from 'types';
import { getAmountError, isValidAddress } from 'utils/validators';
import { Zat, toZat } from 'utils/units';
import { ONE_DAY } from 'utils/time';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
import {
ProposalDetail,
PROPOSAL_DETAIL_INITIAL_STATE,
@ -198,7 +198,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
funded: Zat('0'),
contributionMatching: 0,
percentFunded: 0,
stage: 'preview',
stage: PROPOSAL_STAGE.PREVIEW,
category: draft.category || PROPOSAL_CATEGORY.DAPP,
isStaked: true,
arbiter: {

View File

@ -44,7 +44,7 @@ export function requestPayout(proposalId: number, milestoneId: number) {
export function acceptPayout(proposalId: number, milestoneId: number) {
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.PROPOSAL_PAYOUT_REQUEST,
type: types.PROPOSAL_PAYOUT_ACCEPT,
payload: async () => {
return (await acceptProposalPayout(proposalId, milestoneId)).data;
},
@ -55,7 +55,7 @@ export function acceptPayout(proposalId: number, milestoneId: number) {
export function rejectPayout(proposalId: number, milestoneId: number, reason: string) {
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.PROPOSAL_PAYOUT_REQUEST,
type: types.PROPOSAL_PAYOUT_REJECT,
payload: async () => {
return (await rejectProposalPayout(proposalId, milestoneId, reason)).data;
},

View File

@ -6,7 +6,7 @@ import {
STATUS,
PROPOSAL_ARBITER_STATUS,
} from 'types';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
import BN from 'bn.js';
import moment from 'moment';
@ -158,7 +158,7 @@ export function generateProposal({
title: 'Crowdfund Title',
brief: 'A cool test crowdfund',
content: 'body',
stage: 'FUNDING_REQUIRED',
stage: PROPOSAL_STAGE.WIP,
category: PROPOSAL_CATEGORY.COMMUNITY,
isStaked: true,
arbiter: {

View File

@ -1,5 +1,5 @@
import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
import { ProposalMilestone } from './milestone';
import { RFP } from './rfp';
@ -36,7 +36,7 @@ export interface ProposalDraft {
brief: string;
category: PROPOSAL_CATEGORY;
content: string;
stage: string;
stage: PROPOSAL_STAGE;
target: string;
payoutAddress: string;
deadlineDuration: number;