full payout flow operational
This commit is contained in:
parent
c47c69ea3c
commit
fd9a4c5393
|
@ -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 (
|
||||
|
|
|
@ -37,4 +37,13 @@
|
|||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&-alert {
|
||||
& pre {
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
white-space: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -33,8 +33,9 @@ ProposalSort = ProposalSortEnum()
|
|||
|
||||
|
||||
class ProposalStageEnum(CustomEnum):
|
||||
PREVIEW = 'PREVIEW'
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
IN_PROGRESS = 'IN_PROGRESS'
|
||||
WIP = 'WIP'
|
||||
COMPLETED = 'COMPLETED'
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue