Merge pull request #194 from grant-project/zcash-milestones
Zcash milestones
This commit is contained in:
commit
90d5cae094
|
@ -77,4 +77,24 @@ export default [
|
||||||
title: 'Arbiter assignment',
|
title: 'Arbiter assignment',
|
||||||
description: 'Sent if someone is made arbiter of a proposal',
|
description: 'Sent if someone is made arbiter of a proposal',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_request',
|
||||||
|
title: 'Milestone request',
|
||||||
|
description: 'Sent if team member has made a milestone payout request',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_accept',
|
||||||
|
title: 'Milestone accept',
|
||||||
|
description: 'Sent if arbiter approves milestone payout',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_reject',
|
||||||
|
title: 'Milestone reject',
|
||||||
|
description: 'Sent if arbiter rejects milestone payout',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_paid',
|
||||||
|
title: 'Milestone paid',
|
||||||
|
description: 'Sent when milestone is paid',
|
||||||
|
},
|
||||||
] as Email[];
|
] as Email[];
|
||||||
|
|
|
@ -16,6 +16,7 @@ class Home extends React.Component {
|
||||||
proposalCount,
|
proposalCount,
|
||||||
proposalPendingCount,
|
proposalPendingCount,
|
||||||
proposalNoArbiterCount,
|
proposalNoArbiterCount,
|
||||||
|
proposalMilestonePayoutsCount,
|
||||||
} = store.stats;
|
} = store.stats;
|
||||||
|
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
|
@ -36,6 +37,14 @@ class Home extends React.Component {
|
||||||
to view them.
|
to view them.
|
||||||
</div>
|
</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);
|
].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -37,4 +37,13 @@
|
||||||
max-width: 400px;
|
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 React from 'react';
|
||||||
|
import BN from 'bn.js';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import {
|
import {
|
||||||
|
@ -12,23 +13,26 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
Switch,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import TextArea from 'antd/lib/input/TextArea';
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { formatDateSeconds } from 'util/time';
|
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 { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Info from 'components/Info';
|
import Info from 'components/Info';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import ArbiterControl from 'components/ArbiterControl';
|
import ArbiterControl from 'components/ArbiterControl';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
import { toZat, fromZat } from 'src/util/units';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
const STATE = {
|
const STATE = {
|
||||||
showRejectModal: false,
|
showRejectModal: false,
|
||||||
rejectReason: '',
|
rejectReason: '',
|
||||||
|
paidTxId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = typeof STATE;
|
type State = typeof STATE;
|
||||||
|
@ -68,7 +72,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
type: 'default',
|
type: 'default',
|
||||||
className: 'ProposalDetail-controls-control',
|
className: 'ProposalDetail-controls-control',
|
||||||
block: true,
|
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) => (
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
<div className="ProposalDetail-deet">
|
<div className="ProposalDetail-deet">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
|
@ -264,6 +316,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderRejected()}
|
{renderRejected()}
|
||||||
{renderNominateArbiter()}
|
{renderNominateArbiter()}
|
||||||
{renderNominatedArbiter()}
|
{renderNominatedArbiter()}
|
||||||
|
{renderMilestoneAccepted()}
|
||||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||||
<Collapse.Panel key="brief" header="brief">
|
<Collapse.Panel key="brief" header="brief">
|
||||||
{p.brief}
|
{p.brief}
|
||||||
|
@ -295,12 +348,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderDeetItem('id', p.proposalId)}
|
{renderDeetItem('id', p.proposalId)}
|
||||||
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
||||||
{renderDeetItem('status', p.status)}
|
{renderDeetItem('status', p.status)}
|
||||||
|
{renderDeetItem('stage', p.stage)}
|
||||||
{renderDeetItem('category', p.category)}
|
{renderDeetItem('category', p.category)}
|
||||||
{renderDeetItem('target', p.target)}
|
{renderDeetItem('target', p.target)}
|
||||||
{renderDeetItem('contributed', p.contributed)}
|
{renderDeetItem('contributed', p.contributed)}
|
||||||
{renderDeetItem('funded (inc. matching)', p.funded)}
|
{renderDeetItem('funded (inc. matching)', p.funded)}
|
||||||
{renderDeetItem('matching', p.contributionMatching)}
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
|
|
||||||
{renderDeetItem(
|
{renderDeetItem(
|
||||||
'arbiter',
|
'arbiter',
|
||||||
<>
|
<>
|
||||||
|
@ -365,6 +418,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
store.updateProposalDetail({ contributionMatching });
|
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));
|
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
||||||
|
|
|
@ -95,6 +95,14 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
|
||||||
return data;
|
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) {
|
async function getEmailExample(type: string) {
|
||||||
const { data } = await api.get(`/admin/email/example/${type}`);
|
const { data } = await api.get(`/admin/email/example/${type}`);
|
||||||
return data;
|
return data;
|
||||||
|
@ -154,6 +162,7 @@ const app = store({
|
||||||
proposalCount: 0,
|
proposalCount: 0,
|
||||||
proposalPendingCount: 0,
|
proposalPendingCount: 0,
|
||||||
proposalNoArbiterCount: 0,
|
proposalNoArbiterCount: 0,
|
||||||
|
proposalMilestonePayoutsCount: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
usersFetching: false,
|
usersFetching: false,
|
||||||
|
@ -178,6 +187,7 @@ const app = store({
|
||||||
proposalDetail: null as null | Proposal,
|
proposalDetail: null as null | Proposal,
|
||||||
proposalDetailFetching: false,
|
proposalDetailFetching: false,
|
||||||
proposalDetailApproving: false,
|
proposalDetailApproving: false,
|
||||||
|
proposalDetailMarkingMilestonePaid: false,
|
||||||
|
|
||||||
rfps: [] as RFP[],
|
rfps: [] as RFP[],
|
||||||
rfpsFetching: false,
|
rfpsFetching: false,
|
||||||
|
@ -424,6 +434,17 @@ const app = store({
|
||||||
app.proposalDetailApproving = false;
|
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
|
// Email
|
||||||
|
|
||||||
async getEmailExample(type: string) {
|
async getEmailExample(type: string) {
|
||||||
|
|
|
@ -4,10 +4,24 @@ export interface SocialMedia {
|
||||||
service: string;
|
service: string;
|
||||||
username: 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 {
|
export interface Milestone {
|
||||||
|
id: number;
|
||||||
|
index: number;
|
||||||
content: string;
|
content: string;
|
||||||
dateCreated: string;
|
dateCreated: number;
|
||||||
dateEstimated: string;
|
dateEstimated: number;
|
||||||
|
dateRequested: number;
|
||||||
|
dateAccepted: number;
|
||||||
|
dateRejected: number;
|
||||||
|
datePaid: number;
|
||||||
immediatePayout: boolean;
|
immediatePayout: boolean;
|
||||||
payoutPercent: string;
|
payoutPercent: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
|
@ -61,7 +75,7 @@ export interface Proposal {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
brief: string;
|
brief: string;
|
||||||
status: PROPOSAL_STATUS;
|
status: PROPOSAL_STATUS;
|
||||||
proposalAddress: string;
|
payoutAddress: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
dateApproved: number;
|
dateApproved: number;
|
||||||
datePublished: number;
|
datePublished: number;
|
||||||
|
@ -70,6 +84,7 @@ export interface Proposal {
|
||||||
stage: string;
|
stage: string;
|
||||||
category: string;
|
category: string;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
|
currentMilestone?: Milestone;
|
||||||
team: User[];
|
team: User[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
contractStatus: string;
|
contractStatus: string;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
RFP_STATUSES,
|
RFP_STATUSES,
|
||||||
CONTRIBUTION_STATUSES,
|
CONTRIBUTION_STATUSES,
|
||||||
PROPOSAL_ARBITER_STATUSES,
|
PROPOSAL_ARBITER_STATUSES,
|
||||||
|
MILESTONE_STAGES,
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
@ -41,6 +42,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
|
||||||
color: s.tagColor,
|
color: s.tagColor,
|
||||||
group: 'Arbiter',
|
group: 'Arbiter',
|
||||||
})),
|
})),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
MILESTONE_STAGES.map(s => ({
|
||||||
|
id: `MILESTONE_${s.id}`,
|
||||||
|
display: `Milestone: ${s.tagDisplay}`,
|
||||||
|
color: s.tagColor,
|
||||||
|
group: 'Milestone',
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const proposalFilters: Filters = {
|
export const proposalFilters: Filters = {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
RFP_STATUS,
|
RFP_STATUS,
|
||||||
CONTRIBUTION_STATUS,
|
CONTRIBUTION_STATUS,
|
||||||
PROPOSAL_ARBITER_STATUS,
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
MILESTONE_STAGE,
|
||||||
} from 'src/types';
|
} from 'src/types';
|
||||||
|
|
||||||
export interface StatusSoT<E> {
|
export interface StatusSoT<E> {
|
||||||
|
@ -12,6 +13,39 @@ export interface StatusSoT<E> {
|
||||||
hint: string;
|
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>> = [
|
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.APPROVED,
|
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;
|
|
@ -6,12 +6,19 @@ class FakeUser(object):
|
||||||
title = 'Email Example Dude'
|
title = 'Email Example Dude'
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMilestone(object):
|
||||||
|
id = 123
|
||||||
|
index = 0
|
||||||
|
title = 'Example Milestone'
|
||||||
|
|
||||||
|
|
||||||
class FakeProposal(object):
|
class FakeProposal(object):
|
||||||
id = 123
|
id = 123
|
||||||
title = 'Example proposal'
|
title = 'Example proposal'
|
||||||
brief = 'This is an example proposal'
|
brief = 'This is an example proposal'
|
||||||
content = 'Example example example example'
|
content = 'Example example example example'
|
||||||
target = "100"
|
target = "100"
|
||||||
|
current_milestone = FakeMilestone()
|
||||||
|
|
||||||
|
|
||||||
class FakeContribution(object):
|
class FakeContribution(object):
|
||||||
|
@ -101,7 +108,27 @@ example_email_args = {
|
||||||
},
|
},
|
||||||
'proposal_arbiter': {
|
'proposal_arbiter': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'http://someproposal.com',
|
'proposal_url': 'http://zfnd.org/proposals/999',
|
||||||
'arbitration_url': 'http://arbitrationtab.com',
|
'accept_url': 'http://zfnd.org/email/arbiter?code=blah&proposalId=999',
|
||||||
|
},
|
||||||
|
'milestone_request': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
|
},
|
||||||
|
'milestone_reject': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
|
},
|
||||||
|
'milestone_accept': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'amount': '33',
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
|
},
|
||||||
|
'milestone_paid': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'amount': '33',
|
||||||
|
'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125',
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from flask import Blueprint, request
|
from functools import reduce
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
@ -14,12 +14,14 @@ from grant.proposal.models import (
|
||||||
proposal_contribution_schema,
|
proposal_contribution_schema,
|
||||||
user_proposal_contributions_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.user.models import User, admin_users_schema, admin_user_schema
|
||||||
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
||||||
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
||||||
from grant.utils.misc import make_url
|
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 grant.utils import pagination
|
||||||
|
from grant.settings import EXPLORER_URL
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
from .example_emails import example_email_args
|
from .example_emails import example_email_args
|
||||||
|
@ -66,11 +68,17 @@ def stats():
|
||||||
.filter(Proposal.status == ProposalStatus.LIVE) \
|
.filter(Proposal.status == ProposalStatus.LIVE) \
|
||||||
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
||||||
.scalar()
|
.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 {
|
return {
|
||||||
"userCount": user_count,
|
"userCount": user_count,
|
||||||
"proposalCount": proposal_count,
|
"proposalCount": proposal_count,
|
||||||
"proposalPendingCount": proposal_pending_count,
|
"proposalPendingCount": proposal_pending_count,
|
||||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||||
|
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -253,7 +261,44 @@ def approve_proposal(id, is_approve, reject_reason=None):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_schema.dump(proposal)
|
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)
|
||||||
|
db.session.add(ms)
|
||||||
|
db.session.flush()
|
||||||
|
# check if this is the final ms, and update proposal.stage
|
||||||
|
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()
|
||||||
|
# email TEAM that payout request was PAID
|
||||||
|
amount = Decimal(ms.payout_percent) * Decimal(proposal.target) / 100
|
||||||
|
for member in proposal.team:
|
||||||
|
send_email(member.email_address, 'milestone_paid', {
|
||||||
|
'proposal': proposal,
|
||||||
|
'amount': amount,
|
||||||
|
'tx_explorer_url': f'{EXPLORER_URL}transactions/{tx_id}',
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
return proposal_schema.dump(proposal), 200
|
||||||
|
|
||||||
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
# EMAIL
|
# EMAIL
|
||||||
|
|
|
@ -163,6 +163,52 @@ def proposal_arbiter(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_request(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'Payout request for {p.title} - {ms.title} has been made',
|
||||||
|
'title': f'Milestone payout requested',
|
||||||
|
'preview': f'A payout request for milestone {ms.title} has been made.',
|
||||||
|
'subscription': EmailSubscription.ARBITER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_reject(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'Payout rejected for {p.title} - {ms.title}',
|
||||||
|
'title': f'Milestone payout rejected',
|
||||||
|
'preview': f'The payout for milestone {ms.title} has been rejected.',
|
||||||
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_accept(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
a = email_args['amount']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'Payout approved for {p.title} - {ms.title}!',
|
||||||
|
'title': f'Milestone payout approved',
|
||||||
|
'preview': f'The payout of {a} ZEC for milestone {ms.title} has been approved.',
|
||||||
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_paid(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
a = email_args['amount']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'{p.title} - {ms.title} has been paid!',
|
||||||
|
'title': f'Milestone paid',
|
||||||
|
'preview': f'The milestone {ms.title} payout of {a} ZEC has been paid!',
|
||||||
|
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
get_info_lookup = {
|
get_info_lookup = {
|
||||||
'signup': signup_info,
|
'signup': signup_info,
|
||||||
'team_invite': team_invite_info,
|
'team_invite': team_invite_info,
|
||||||
|
@ -178,7 +224,11 @@ get_info_lookup = {
|
||||||
'contribution_confirmed': contribution_confirmed,
|
'contribution_confirmed': contribution_confirmed,
|
||||||
'contribution_update': contribution_update,
|
'contribution_update': contribution_update,
|
||||||
'comment_reply': comment_reply,
|
'comment_reply': comment_reply,
|
||||||
'proposal_arbiter': proposal_arbiter
|
'proposal_arbiter': proposal_arbiter,
|
||||||
|
'milestone_request': milestone_request,
|
||||||
|
'milestone_reject': milestone_reject,
|
||||||
|
'milestone_accept': milestone_accept,
|
||||||
|
'milestone_paid': milestone_paid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,39 +2,55 @@ import datetime
|
||||||
|
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import dt_to_unix
|
from grant.utils.ma_fields import UnixDate
|
||||||
|
from grant.utils.enums import MilestoneStage
|
||||||
|
|
||||||
NOT_REQUESTED = 'NOT_REQUESTED'
|
|
||||||
ONGOING_VOTE = 'ONGOING_VOTE'
|
class MilestoneException(Exception):
|
||||||
PAID = 'PAID'
|
pass
|
||||||
MILESTONE_STAGES = [NOT_REQUESTED, ONGOING_VOTE, PAID]
|
|
||||||
|
|
||||||
|
|
||||||
class Milestone(db.Model):
|
class Milestone(db.Model):
|
||||||
__tablename__ = "milestone"
|
__tablename__ = "milestone"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
index = db.Column(db.Integer(), nullable=False)
|
||||||
date_created = db.Column(db.DateTime, nullable=False)
|
date_created = db.Column(db.DateTime, nullable=False)
|
||||||
|
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
|
||||||
payout_percent = db.Column(db.String(255), nullable=False)
|
payout_percent = db.Column(db.String(255), nullable=False)
|
||||||
immediate_payout = db.Column(db.Boolean)
|
immediate_payout = db.Column(db.Boolean)
|
||||||
|
# TODO: change to estimated_duration (sec or ms) -- FE can calc from dates on draft
|
||||||
date_estimated = db.Column(db.DateTime, nullable=False)
|
date_estimated = db.Column(db.DateTime, nullable=False)
|
||||||
|
|
||||||
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
date_requested = db.Column(db.DateTime, nullable=True)
|
||||||
|
requested_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||||
|
|
||||||
|
date_rejected = db.Column(db.DateTime, nullable=True)
|
||||||
|
reject_reason = db.Column(db.String(255))
|
||||||
|
reject_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||||
|
|
||||||
|
date_accepted = db.Column(db.DateTime, nullable=True)
|
||||||
|
accept_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||||
|
|
||||||
|
date_paid = db.Column(db.DateTime, nullable=True)
|
||||||
|
paid_tx_id = db.Column(db.String(255))
|
||||||
|
|
||||||
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
index: int,
|
||||||
title: str,
|
title: str,
|
||||||
content: str,
|
content: str,
|
||||||
date_estimated: datetime,
|
date_estimated: datetime,
|
||||||
payout_percent: str,
|
payout_percent: str,
|
||||||
immediate_payout: bool,
|
immediate_payout: bool,
|
||||||
stage: str = NOT_REQUESTED,
|
stage: str = MilestoneStage.IDLE,
|
||||||
proposal_id=int
|
proposal_id=int,
|
||||||
):
|
):
|
||||||
self.title = title
|
self.title = title
|
||||||
self.content = content
|
self.content = content
|
||||||
|
@ -44,12 +60,42 @@ class Milestone(db.Model):
|
||||||
self.immediate_payout = immediate_payout
|
self.immediate_payout = immediate_payout
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
self.date_created = datetime.datetime.now()
|
self.date_created = datetime.datetime.now()
|
||||||
|
self.index = index
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate(milestone):
|
def validate(milestone):
|
||||||
if len(milestone.title) > 60:
|
if len(milestone.title) > 60:
|
||||||
raise ValidationException("Milestone title must be no more than 60 chars")
|
raise ValidationException("Milestone title must be no more than 60 chars")
|
||||||
|
|
||||||
|
def request_payout(self, user_id: int):
|
||||||
|
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||||
|
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||||
|
self.stage = MilestoneStage.REQUESTED
|
||||||
|
self.date_requested = datetime.datetime.now()
|
||||||
|
self.requested_user_id = user_id
|
||||||
|
|
||||||
|
def reject_request(self, arbiter_id: int, reason: str):
|
||||||
|
if self.stage != MilestoneStage.REQUESTED:
|
||||||
|
raise MilestoneException(f'Cannot reject payout request for milestone at {self.stage} stage')
|
||||||
|
self.stage = MilestoneStage.REJECTED
|
||||||
|
self.date_rejected = datetime.datetime.now()
|
||||||
|
self.reject_reason = reason
|
||||||
|
self.reject_arbiter_id = arbiter_id
|
||||||
|
|
||||||
|
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.ACCEPTED
|
||||||
|
self.date_accepted = datetime.datetime.now()
|
||||||
|
self.accept_arbiter_id = arbiter_id
|
||||||
|
|
||||||
|
def mark_paid(self, tx_id: str):
|
||||||
|
if self.stage != MilestoneStage.ACCEPTED:
|
||||||
|
raise MilestoneException(f'Cannot pay a milestone at {self.stage} stage')
|
||||||
|
self.stage = MilestoneStage.PAID
|
||||||
|
self.date_paid = datetime.datetime.now()
|
||||||
|
self.paid_tx_id = tx_id
|
||||||
|
|
||||||
|
|
||||||
class MilestoneSchema(ma.Schema):
|
class MilestoneSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -57,22 +103,28 @@ class MilestoneSchema(ma.Schema):
|
||||||
# Fields to expose
|
# Fields to expose
|
||||||
fields = (
|
fields = (
|
||||||
"title",
|
"title",
|
||||||
|
"index",
|
||||||
|
"id",
|
||||||
"content",
|
"content",
|
||||||
"stage",
|
"stage",
|
||||||
"date_estimated",
|
|
||||||
"payout_percent",
|
"payout_percent",
|
||||||
"immediate_payout",
|
"immediate_payout",
|
||||||
|
"reject_reason",
|
||||||
|
"paid_tx_id",
|
||||||
"date_created",
|
"date_created",
|
||||||
|
"date_estimated",
|
||||||
|
"date_requested",
|
||||||
|
"date_rejected",
|
||||||
|
"date_accepted",
|
||||||
|
"date_paid",
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = UnixDate(attribute='date_created')
|
||||||
date_estimated = ma.Method("get_date_estimated")
|
date_estimated = UnixDate(attribute='date_estimated')
|
||||||
|
date_requested = UnixDate(attribute='date_requested')
|
||||||
def get_date_created(self, obj):
|
date_rejected = UnixDate(attribute='date_rejected')
|
||||||
return dt_to_unix(obj.date_created)
|
date_accepted = UnixDate(attribute='date_accepted')
|
||||||
|
date_paid = UnixDate(attribute='date_paid')
|
||||||
def get_date_estimated(self, obj):
|
|
||||||
return dt_to_unix(obj.date_estimated) if obj.date_estimated else None
|
|
||||||
|
|
||||||
|
|
||||||
milestone_schema = MilestoneSchema()
|
milestone_schema = MilestoneSchema()
|
||||||
|
|
|
@ -10,8 +10,15 @@ from grant.extensions import ma, db
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import dt_to_unix, make_url
|
from grant.utils.misc import dt_to_unix, make_url
|
||||||
from grant.utils.requests import blockchain_get
|
from grant.utils.requests import blockchain_get
|
||||||
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
|
|
||||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
|
from grant.utils.enums import (
|
||||||
|
ProposalStatus,
|
||||||
|
ProposalStage,
|
||||||
|
Category,
|
||||||
|
ContributionStatus,
|
||||||
|
ProposalArbiterStatus,
|
||||||
|
MilestoneStage
|
||||||
|
)
|
||||||
|
|
||||||
proposal_team = db.Table(
|
proposal_team = db.Table(
|
||||||
'proposal_team', db.Model.metadata,
|
'proposal_team', db.Model.metadata,
|
||||||
|
@ -214,7 +221,8 @@ class Proposal(db.Model):
|
||||||
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
updates = db.relationship(ProposalUpdate, 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")
|
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
milestones = db.relationship("Milestone", 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")
|
||||||
invites = db.relationship(ProposalTeamInvite, backref="proposal", 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")
|
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@ -224,7 +232,7 @@ class Proposal(db.Model):
|
||||||
title: str = '',
|
title: str = '',
|
||||||
brief: str = '',
|
brief: str = '',
|
||||||
content: str = '',
|
content: str = '',
|
||||||
stage: str = '',
|
stage: str = ProposalStage.PREVIEW,
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
deadline_duration: int = 5184000, # 60 days
|
deadline_duration: int = 5184000, # 60 days
|
||||||
|
@ -394,6 +402,7 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.status = ProposalStatus.LIVE
|
self.status = ProposalStatus.LIVE
|
||||||
|
self.stage = ProposalStage.FUNDING_REQUIRED
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def contributed(self):
|
def contributed(self):
|
||||||
|
@ -418,6 +427,19 @@ class Proposal(db.Model):
|
||||||
def is_staked(self):
|
def is_staked(self):
|
||||||
return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT
|
return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def is_funded(self):
|
||||||
|
return Decimal(self.contributed) >= Decimal(self.target)
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def current_milestone(self):
|
||||||
|
if self.milestones:
|
||||||
|
for ms in self.milestones:
|
||||||
|
if ms.stage != MilestoneStage.PAID:
|
||||||
|
return ms
|
||||||
|
return self.milestones[-1] # return last one if all PAID
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ProposalSchema(ma.Schema):
|
class ProposalSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -441,6 +463,7 @@ class ProposalSchema(ma.Schema):
|
||||||
"comments",
|
"comments",
|
||||||
"updates",
|
"updates",
|
||||||
"milestones",
|
"milestones",
|
||||||
|
"current_milestone",
|
||||||
"category",
|
"category",
|
||||||
"team",
|
"team",
|
||||||
"payout_address",
|
"payout_address",
|
||||||
|
@ -460,6 +483,7 @@ class ProposalSchema(ma.Schema):
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||||
|
current_milestone = ma.Nested("MilestoneSchema")
|
||||||
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
||||||
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
||||||
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
from decimal import Decimal
|
||||||
from flask import Blueprint, g, request
|
from flask import Blueprint, g, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
from grant.comment.models import Comment, comment_schema, comments_schema
|
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||||
|
@ -10,13 +11,14 @@ from grant.rfp.models import RFP
|
||||||
from grant.utils.auth import (
|
from grant.utils.auth import (
|
||||||
requires_auth,
|
requires_auth,
|
||||||
requires_team_member_auth,
|
requires_team_member_auth,
|
||||||
|
requires_arbiter_auth,
|
||||||
requires_email_verified_auth,
|
requires_email_verified_auth,
|
||||||
get_authed_user,
|
get_authed_user,
|
||||||
internal_webhook
|
internal_webhook
|
||||||
)
|
)
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import is_email, make_url, from_zat
|
from grant.utils.misc import is_email, make_url, from_zat
|
||||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -213,14 +215,15 @@ def update_proposal(milestones, proposal_id, **kwargs):
|
||||||
# Delete & re-add milestones
|
# Delete & re-add milestones
|
||||||
[db.session.delete(x) for x in g.current_proposal.milestones]
|
[db.session.delete(x) for x in g.current_proposal.milestones]
|
||||||
if milestones:
|
if milestones:
|
||||||
for mdata in milestones:
|
for i, mdata in enumerate(milestones):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=mdata["title"],
|
title=mdata["title"],
|
||||||
content=mdata["content"],
|
content=mdata["content"],
|
||||||
date_estimated=datetime.fromtimestamp(mdata["dateEstimated"]),
|
date_estimated=datetime.fromtimestamp(mdata["dateEstimated"]),
|
||||||
payout_percent=str(mdata["payoutPercent"]),
|
payout_percent=str(mdata["payoutPercent"]),
|
||||||
immediate_payout=mdata["immediatePayout"],
|
immediate_payout=mdata["immediatePayout"],
|
||||||
proposal_id=g.current_proposal.id
|
proposal_id=g.current_proposal.id,
|
||||||
|
index=i
|
||||||
)
|
)
|
||||||
db.session.add(m)
|
db.session.add(m)
|
||||||
|
|
||||||
|
@ -480,14 +483,14 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
|
|
||||||
contribution.confirm(tx_id=txid, amount=zec_amount)
|
contribution.confirm(tx_id=txid, amount=zec_amount)
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.commit()
|
db.session.flush()
|
||||||
|
|
||||||
if contribution.proposal.status == ProposalStatus.STAKING:
|
if contribution.proposal.status == ProposalStatus.STAKING:
|
||||||
# fully staked, set status PENDING & notify user
|
# fully staked, set status PENDING
|
||||||
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
|
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
|
||||||
contribution.proposal.status = ProposalStatus.PENDING
|
contribution.proposal.status = ProposalStatus.PENDING
|
||||||
db.session.add(contribution.proposal)
|
db.session.add(contribution.proposal)
|
||||||
db.session.commit()
|
db.session.flush()
|
||||||
|
|
||||||
# email progress of staking, partial or complete
|
# email progress of staking, partial or complete
|
||||||
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
||||||
|
@ -519,7 +522,13 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
|
|
||||||
# TODO: Once we have a task queuer in place, queue emails to everyone
|
# TODO: Once we have a task queuer in place, queue emails to everyone
|
||||||
# on funding target reached.
|
# on funding target reached.
|
||||||
|
if contribution.proposal.status == ProposalStatus.LIVE:
|
||||||
|
if contribution.proposal.is_funded:
|
||||||
|
contribution.proposal.stage = ProposalStage.WIP
|
||||||
|
db.session.add(contribution.proposal)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
return None, 200
|
return None, 200
|
||||||
|
|
||||||
|
|
||||||
|
@ -542,3 +551,76 @@ def delete_proposal_contribution(contribution_id):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return None, 202
|
return None, 202
|
||||||
|
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
ms.request_payout(g.current_user.id)
|
||||||
|
db.session.add(ms)
|
||||||
|
db.session.commit()
|
||||||
|
# email ARBITER to review payout request
|
||||||
|
send_email(g.current_proposal.arbiter.user.email_address, 'milestone_request', {
|
||||||
|
'proposal': g.current_proposal,
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
# accept MS payout (arbiter)
|
||||||
|
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/accept", methods=["PUT"])
|
||||||
|
@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):
|
||||||
|
ms.accept_request(g.current_user.id)
|
||||||
|
db.session.add(ms)
|
||||||
|
db.session.commit()
|
||||||
|
# email TEAM that payout request accepted
|
||||||
|
amount = Decimal(ms.payout_percent) * Decimal(g.current_proposal.target) / 100
|
||||||
|
for member in g.current_proposal.team:
|
||||||
|
send_email(member.email_address, 'milestone_accept', {
|
||||||
|
'proposal': g.current_proposal,
|
||||||
|
'amount': amount,
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
# reject MS payout (arbiter) (reason)
|
||||||
|
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/reject", methods=["PUT"])
|
||||||
|
@requires_arbiter_auth
|
||||||
|
@endpoint.api(
|
||||||
|
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):
|
||||||
|
ms.reject_request(g.current_user.id, reason)
|
||||||
|
db.session.add(ms)
|
||||||
|
db.session.commit()
|
||||||
|
# email TEAM that payout request was rejected
|
||||||
|
for member in g.current_proposal.team:
|
||||||
|
send_email(member.email_address, 'milestone_reject', {
|
||||||
|
'proposal': g.current_proposal,
|
||||||
|
'admin_note': reason,
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
The proposal milestone
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||||
|
</a>
|
||||||
|
payout of <b>{{ args.amount }} ZEC</b> has been approved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0;">
|
||||||
|
You will receive payment shortly!
|
||||||
|
</p>
|
|
@ -0,0 +1,6 @@
|
||||||
|
The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
|
||||||
|
payout of {{args.amount}} ZEC has been approved!
|
||||||
|
|
||||||
|
You will receive payment shortly!
|
||||||
|
|
||||||
|
View the milestone: {{ args.proposal_milestones_url }}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
|
||||||
|
>! You can view the transaction below:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
|
||||||
|
{{ args.tx_explorer_url }}
|
||||||
|
</a>
|
||||||
|
</p>
|
|
@ -0,0 +1,6 @@
|
||||||
|
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"!
|
||||||
|
You can view the transaction below:
|
||||||
|
|
||||||
|
{{ args.tx_explorer_url }}
|
||||||
|
|
||||||
|
View the milestone: {{ args.proposal_milestones_url }}
|
|
@ -0,0 +1,21 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
The payout request for proposal milestone
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||||
|
</a>
|
||||||
|
has been rejected.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
The following reason was provided:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Another request for payment can be made when the above concerns have been
|
||||||
|
addressed.
|
||||||
|
</p>
|
|
@ -0,0 +1,12 @@
|
||||||
|
The payout request for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
|
||||||
|
has been rejected.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
The following reason was provided:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Another request for payment can be made when the above concerns have been addressed.
|
||||||
|
|
||||||
|
View milestone: {{ args.proposal_milestones_url }}
|
|
@ -0,0 +1,33 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
A payout request for the proposal milestone
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||||
|
</a>
|
||||||
|
has been made. As arbiter, you are responsible for reviewing this request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_milestones_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
Review the request
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,4 @@
|
||||||
|
A payout request for the proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
|
||||||
|
has been made. As arbiter, you are responsible for reviewing this request.
|
||||||
|
|
||||||
|
Review the request: {{ args.proposal_milestones_url }}
|
|
@ -76,6 +76,26 @@ def requires_team_member_auth(f):
|
||||||
return requires_email_verified_auth(decorated)
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
|
def requires_arbiter_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
proposal_id = kwargs["proposal_id"]
|
||||||
|
if not proposal_id:
|
||||||
|
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500
|
||||||
|
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return jsonify(message="No proposal exists with id {}".format(proposal_id)), 404
|
||||||
|
|
||||||
|
if g.current_user != proposal.arbiter.user:
|
||||||
|
return jsonify(message="You are not arbiter this proposal"), 403
|
||||||
|
|
||||||
|
g.current_proposal = proposal
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
def internal_webhook(f):
|
def internal_webhook(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
|
|
@ -33,7 +33,9 @@ ProposalSort = ProposalSortEnum()
|
||||||
|
|
||||||
|
|
||||||
class ProposalStageEnum(CustomEnum):
|
class ProposalStageEnum(CustomEnum):
|
||||||
|
PREVIEW = 'PREVIEW'
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||||
|
WIP = 'WIP'
|
||||||
COMPLETED = 'COMPLETED'
|
COMPLETED = 'COMPLETED'
|
||||||
|
|
||||||
|
|
||||||
|
@ -69,6 +71,17 @@ class RFPStatusEnum(CustomEnum):
|
||||||
RFPStatus = RFPStatusEnum()
|
RFPStatus = RFPStatusEnum()
|
||||||
|
|
||||||
|
|
||||||
|
class MilestoneStageEnum(CustomEnum):
|
||||||
|
IDLE = 'IDLE'
|
||||||
|
REQUESTED = 'REQUESTED'
|
||||||
|
REJECTED = 'REJECTED'
|
||||||
|
ACCEPTED = 'ACCEPTED'
|
||||||
|
PAID = 'PAID'
|
||||||
|
|
||||||
|
|
||||||
|
MilestoneStage = MilestoneStageEnum()
|
||||||
|
|
||||||
|
|
||||||
class ProposalArbiterStatusEnum(CustomEnum):
|
class ProposalArbiterStatusEnum(CustomEnum):
|
||||||
MISSING = 'MISSING'
|
MISSING = 'MISSING'
|
||||||
NOMINATED = 'NOMINATED'
|
NOMINATED = 'NOMINATED'
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
from grant.extensions import ma
|
||||||
|
from .misc import dt_to_unix
|
||||||
|
|
||||||
|
|
||||||
|
class UnixDate(ma.Field):
|
||||||
|
def _serialize(self, value, attr, obj, **kwargs):
|
||||||
|
return dt_to_unix(value) if value else None
|
|
@ -2,7 +2,8 @@ import abc
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
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):
|
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'STAGE_{s}' for s in ProposalStage.list()])
|
||||||
self.FILTERS.extend([f'CAT_{c}' for c in Category.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'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
||||||
|
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
|
||||||
self.PAGE_SIZE = 9
|
self.PAGE_SIZE = 9
|
||||||
self.SORT_MAP = {
|
self.SORT_MAP = {
|
||||||
'CREATED:DESC': Proposal.date_created.desc(),
|
'CREATED:DESC': Proposal.date_created.desc(),
|
||||||
|
@ -77,6 +79,7 @@ class ProposalPagination(Pagination):
|
||||||
stage_filters = extract_filters('STAGE_', filters)
|
stage_filters = extract_filters('STAGE_', filters)
|
||||||
cat_filters = extract_filters('CAT_', filters)
|
cat_filters = extract_filters('CAT_', filters)
|
||||||
arbiter_filters = extract_filters('ARBITER_', filters)
|
arbiter_filters = extract_filters('ARBITER_', filters)
|
||||||
|
milestone_filters = extract_filters('MILESTONE_', filters)
|
||||||
|
|
||||||
if status_filters:
|
if status_filters:
|
||||||
query = query.filter(Proposal.status.in_(status_filters))
|
query = query.filter(Proposal.status.in_(status_filters))
|
||||||
|
@ -89,6 +92,9 @@ class ProposalPagination(Pagination):
|
||||||
if arbiter_filters:
|
if arbiter_filters:
|
||||||
query = query.join(Proposal.arbiter) \
|
query = query.join(Proposal.arbiter) \
|
||||||
.filter(ProposalArbiter.status.in_(arbiter_filters))
|
.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)
|
# SORT (see self.SORT_MAP)
|
||||||
if sort:
|
if sort:
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""milestone payment fields
|
||||||
|
|
||||||
|
Revision ID: 3793d9a71e27
|
||||||
|
Revises: 86d300cb6d69
|
||||||
|
Create Date: 2019-02-11 11:01:44.703413
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3793d9a71e27'
|
||||||
|
down_revision = '86d300cb6d69'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('milestone', sa.Column('accept_arbiter_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('date_accepted', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('date_paid', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('date_rejected', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('date_requested', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('index', sa.Integer(), nullable=False))
|
||||||
|
op.add_column('milestone', sa.Column('paid_tx_id', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('reject_arbiter_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('reject_reason', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('milestone', sa.Column('requested_user_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(None, 'milestone', 'user', ['accept_arbiter_id'], ['id'])
|
||||||
|
op.create_foreign_key(None, 'milestone', 'user', ['reject_arbiter_id'], ['id'])
|
||||||
|
op.create_foreign_key(None, 'milestone', 'user', ['requested_user_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'milestone', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'milestone', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'milestone', type_='foreignkey')
|
||||||
|
op.drop_column('milestone', 'requested_user_id')
|
||||||
|
op.drop_column('milestone', 'reject_reason')
|
||||||
|
op.drop_column('milestone', 'reject_arbiter_id')
|
||||||
|
op.drop_column('milestone', 'paid_tx_id')
|
||||||
|
op.drop_column('milestone', 'index')
|
||||||
|
op.drop_column('milestone', 'date_requested')
|
||||||
|
op.drop_column('milestone', 'date_rejected')
|
||||||
|
op.drop_column('milestone', 'date_paid')
|
||||||
|
op.drop_column('milestone', 'date_accepted')
|
||||||
|
op.drop_column('milestone', 'accept_arbiter_id')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -1,4 +1,4 @@
|
||||||
"""empty message
|
"""proposal_arbiter table
|
||||||
|
|
||||||
Revision ID: 86d300cb6d69
|
Revision ID: 86d300cb6d69
|
||||||
Revises: 310dca400b81
|
Revises: 310dca400b81
|
||||||
|
@ -17,23 +17,23 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('proposal_arbiter',
|
op.create_table('proposal_arbiter',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
sa.Column('status', sa.String(length=255), nullable=False),
|
sa.Column('status', sa.String(length=255), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey')
|
op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey')
|
||||||
op.drop_column('proposal', 'arbiter_id')
|
op.drop_column('proposal', 'arbiter_id')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True))
|
op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||||
op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id'])
|
op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id'])
|
||||||
op.drop_table('proposal_arbiter')
|
op.drop_table('proposal_arbiter')
|
||||||
|
|
|
@ -228,6 +228,41 @@ export async function putProposalPublish(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestProposalPayout(
|
||||||
|
proposalId: number,
|
||||||
|
milestoneId: number,
|
||||||
|
): Promise<{ data: Proposal }> {
|
||||||
|
return axios
|
||||||
|
.put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/request`)
|
||||||
|
.then(res => {
|
||||||
|
res.data = formatProposalFromGet(res.data);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function acceptProposalPayout(
|
||||||
|
proposalId: number,
|
||||||
|
milestoneId: number,
|
||||||
|
): Promise<{ data: Proposal }> {
|
||||||
|
return axios
|
||||||
|
.put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/accept`)
|
||||||
|
.then(res => {
|
||||||
|
res.data = formatProposalFromGet(res.data);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function rejectProposalPayout(
|
||||||
|
proposalId: number,
|
||||||
|
milestoneId: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ data: Proposal }> {
|
||||||
|
return axios
|
||||||
|
.put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/reject`, { reason })
|
||||||
|
.then(res => {
|
||||||
|
res.data = formatProposalFromGet(res.data);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function postProposalInvite(
|
export function postProposalInvite(
|
||||||
proposalId: number,
|
proposalId: number,
|
||||||
address: string,
|
address: string,
|
||||||
|
|
|
@ -51,6 +51,7 @@ export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum PROPOSAL_STAGE {
|
export enum PROPOSAL_STAGE {
|
||||||
|
PREVIEW = 'PREVIEW',
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||||
WIP = 'WIP',
|
WIP = 'WIP',
|
||||||
COMPLETED = 'COMPLETED',
|
COMPLETED = 'COMPLETED',
|
||||||
|
@ -62,6 +63,10 @@ interface StageUI {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
|
export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
|
||||||
|
PREVIEW: {
|
||||||
|
label: 'Preview',
|
||||||
|
color: '#8e44ad',
|
||||||
|
},
|
||||||
FUNDING_REQUIRED: {
|
FUNDING_REQUIRED: {
|
||||||
label: 'Funding required',
|
label: 'Funding required',
|
||||||
color: '#8e44ad',
|
color: '#8e44ad',
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { connect } from 'react-redux';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { updateUserArbiter } from 'api/api';
|
import { updateUserArbiter } from 'api/api';
|
||||||
|
@ -27,15 +28,32 @@ type Props = OwnProps & StateProps & DispatchProps;
|
||||||
class ProfileArbitrated extends React.Component<Props, {}> {
|
class ProfileArbitrated extends React.Component<Props, {}> {
|
||||||
render() {
|
render() {
|
||||||
const { status } = this.props.arbiter;
|
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 = {
|
const info = {
|
||||||
[PAS.MISSING]: <>{/* nada */}</>,
|
[PAS.MISSING]: <>{/* nada */}</>,
|
||||||
[PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal.</>,
|
[PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal.</>,
|
||||||
[PAS.ACCEPTED]: (
|
[PAS.ACCEPTED]: (
|
||||||
<>
|
<>
|
||||||
As arbiter of this proposal, you are responsible for reviewing milestone payout
|
{isMsPayoutReq && (
|
||||||
requests. You may{' '}
|
<>
|
||||||
|
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
|
<Popconfirm
|
||||||
title="Stop acting as arbiter?"
|
title="Stop acting as arbiter?"
|
||||||
onConfirm={() => this.acceptArbiter(false)}
|
onConfirm={() => this.acceptArbiter(false)}
|
||||||
|
@ -57,7 +75,15 @@ class ProfileArbitrated extends React.Component<Props, {}> {
|
||||||
<Button onClick={() => this.acceptArbiter(false)}>Reject</Button>
|
<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 (
|
return (
|
||||||
|
|
|
@ -39,6 +39,11 @@
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-alert {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
display: none;
|
display: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -97,6 +102,31 @@
|
||||||
flex: 1;
|
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 {
|
&-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
|
@ -1,17 +1,27 @@
|
||||||
import lodash from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { Alert, Steps } from 'antd';
|
import classnames from 'classnames';
|
||||||
import { Proposal, MILESTONE_STATE } from 'types';
|
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,
|
||||||
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
} from 'types';
|
||||||
|
import { PROPOSAL_STAGE } from 'api/constants';
|
||||||
import UnitDisplay from 'components/UnitDisplay';
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import './style.less';
|
|
||||||
import Placeholder from 'components/Placeholder';
|
import Placeholder from 'components/Placeholder';
|
||||||
|
import { proposalActions } from 'modules/proposals';
|
||||||
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
|
import { ProposalDetail } from 'modules/proposals/reducers';
|
||||||
|
import './index.less';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
enum STEP_STATUS {
|
enum STEP_STATUS {
|
||||||
WAIT = 'wait',
|
WAIT = 'wait',
|
||||||
|
@ -20,53 +30,68 @@ enum STEP_STATUS {
|
||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
const milestoneStateToStepState = {
|
const milestoneStageToStepState = {
|
||||||
[WAITING]: STEP_STATUS.WAIT,
|
[MILESTONE_STAGE.IDLE]: STEP_STATUS.WAIT,
|
||||||
[ACTIVE]: STEP_STATUS.PROCESS,
|
[MILESTONE_STAGE.REQUESTED]: STEP_STATUS.PROCESS,
|
||||||
[PAID]: STEP_STATUS.FINISH,
|
[MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.PROCESS,
|
||||||
[REJECTED]: STEP_STATUS.ERROR,
|
[MILESTONE_STAGE.REJECTED]: STEP_STATUS.ERROR,
|
||||||
};
|
[MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.FINISH,
|
||||||
|
} as { [key in MILESTONE_STAGE]: StepProps['status'] };
|
||||||
|
|
||||||
|
const fmtDate = (n: undefined | number) =>
|
||||||
|
(n && moment(n * 1000).format('MMM Do, YYYY, h:mm a')) || undefined;
|
||||||
|
|
||||||
|
const fmtDateFromNow = (n: undefined | number) => (n && moment(n * 1000).fromNow()) || '';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
proposal: Proposal;
|
proposal: ProposalDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateProps {
|
interface DispatchProps {
|
||||||
accounts: string[];
|
requestPayout: typeof proposalActions.requestPayout;
|
||||||
|
acceptPayout: typeof proposalActions.acceptPayout;
|
||||||
|
rejectPayout: typeof proposalActions.rejectPayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps;
|
type Props = OwnProps & DispatchProps;
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
step: number;
|
step: number;
|
||||||
activeMilestoneIdx: number;
|
activeMilestoneIdx: number;
|
||||||
doTitlesOverflow: boolean;
|
doTitlesOverflow: boolean;
|
||||||
|
showRejectModal: boolean;
|
||||||
|
rejectReason: string;
|
||||||
|
rejectMilestoneId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProposalMilestones extends React.Component<Props, State> {
|
class ProposalMilestones extends React.Component<Props, State> {
|
||||||
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
|
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
|
||||||
ref: React.RefObject<HTMLDivElement>;
|
ref: React.RefObject<HTMLDivElement>;
|
||||||
|
rejectInput: null | TextArea;
|
||||||
throttledUpdateDoTitlesOverflow: () => void;
|
throttledUpdateDoTitlesOverflow: () => void;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
this.rejectInput = null;
|
||||||
this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef());
|
this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef());
|
||||||
this.ref = React.createRef();
|
this.ref = React.createRef();
|
||||||
this.throttledUpdateDoTitlesOverflow = lodash.throttle(
|
this.throttledUpdateDoTitlesOverflow = throttle(this.updateDoTitlesOverflow, 500);
|
||||||
this.updateDoTitlesOverflow,
|
const step =
|
||||||
500,
|
(this.props.proposal &&
|
||||||
);
|
this.props.proposal.currentMilestone &&
|
||||||
|
this.props.proposal.currentMilestone.index) ||
|
||||||
|
0;
|
||||||
this.state = {
|
this.state = {
|
||||||
step: 0,
|
step,
|
||||||
activeMilestoneIdx: 0,
|
activeMilestoneIdx: 0,
|
||||||
doTitlesOverflow: true,
|
doTitlesOverflow: true,
|
||||||
|
showRejectModal: false,
|
||||||
|
rejectReason: '',
|
||||||
|
rejectMilestoneId: -1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.props.proposal) {
|
|
||||||
const activeMilestoneIdx = this.getActiveMilestoneIdx();
|
|
||||||
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
|
|
||||||
}
|
|
||||||
this.updateDoTitlesOverflow();
|
this.updateDoTitlesOverflow();
|
||||||
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
||||||
}
|
}
|
||||||
|
@ -74,123 +99,116 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(_: Props, prevState: State) {
|
componentDidUpdate(prevProps: Props, _: State) {
|
||||||
const activeMilestoneIdx = this.getActiveMilestoneIdx();
|
const cm = this.props.proposal.currentMilestone;
|
||||||
if (prevState.activeMilestoneIdx !== activeMilestoneIdx) {
|
const pcm = prevProps.proposal.currentMilestone;
|
||||||
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
|
const cmId = (cm && cm.id) || 0;
|
||||||
|
const pcmId = (pcm && pcm.id) || 0;
|
||||||
|
if (pcmId !== cmId) {
|
||||||
|
this.setState({ step: (cm && cm.index) || 0 });
|
||||||
|
}
|
||||||
|
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() {
|
render() {
|
||||||
const { proposal } = this.props;
|
const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props;
|
||||||
|
const { rejectReason, showRejectModal } = this.state;
|
||||||
if (!proposal) {
|
if (!proposal) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
const { milestones } = proposal;
|
const { milestones, currentMilestone, isRejectingPayout } = proposal;
|
||||||
|
|
||||||
const isTrustee = false; // TODO: Replace with being on the team
|
|
||||||
const milestoneCount = milestones.length;
|
const milestoneCount = milestones.length;
|
||||||
|
|
||||||
const milestoneSteps = milestones.map((milestone, i) => {
|
// 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 =
|
const status =
|
||||||
this.state.activeMilestoneIdx === i && milestone.state === WAITING
|
currentMilestone &&
|
||||||
|
currentMilestone.index === i &&
|
||||||
|
ms.stage === MILESTONE_STAGE.IDLE
|
||||||
? STEP_STATUS.PROCESS
|
? STEP_STATUS.PROCESS
|
||||||
: milestoneStateToStepState[milestone.state];
|
: milestoneStageToStepState[ms.stage];
|
||||||
|
|
||||||
const className = this.state.step === i ? 'is-active' : 'is-inactive';
|
const className = this.state.step === i ? 'is-active' : 'is-inactive';
|
||||||
const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY');
|
|
||||||
const reward = (
|
|
||||||
<UnitDisplay value={milestone.amount} symbol="ZEC" displayShortBalance={4} />
|
|
||||||
);
|
|
||||||
const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' };
|
|
||||||
|
|
||||||
const stepProps = {
|
const stepProps = {
|
||||||
title: <div ref={this.stepTitleRefs[i]}>{milestone.title}</div>,
|
title: <div ref={this.stepTitleRefs[i]}>{ms.title}</div>,
|
||||||
status,
|
status,
|
||||||
className,
|
className,
|
||||||
onClick: () => this.setState({ step: i }),
|
onClick: () => this.setState({ step: i }),
|
||||||
};
|
};
|
||||||
|
return { key: i, stepProps };
|
||||||
let notification;
|
|
||||||
|
|
||||||
switch (milestone.state) {
|
|
||||||
case PAID:
|
|
||||||
notification = (
|
|
||||||
<Alert
|
|
||||||
type="success"
|
|
||||||
message={
|
|
||||||
<span>
|
|
||||||
The team was awarded <strong>{reward}</strong>{' '}
|
|
||||||
{milestone.immediatePayout
|
|
||||||
? 'as an initial payout'
|
|
||||||
: // TODO: Add property for payout date on milestones
|
|
||||||
`on ${moment().format('MMM Do, YYYY')}`}
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
style={alertStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case ACTIVE:
|
|
||||||
notification = (
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
message={`
|
|
||||||
The team has requested a payout for this milestone. It is
|
|
||||||
currently under review.
|
|
||||||
`}
|
|
||||||
style={alertStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case REJECTED:
|
|
||||||
notification = (
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
message={
|
|
||||||
<span>
|
|
||||||
Payout for this milestone was rejected on{' '}
|
|
||||||
{/* TODO: add property for payout rejection date on milestones */}
|
|
||||||
{moment().format('MMM Do, YYYY')}.{isTrustee ? ' You ' : ' The team '}{' '}
|
|
||||||
can request another review for payout at any time.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
style={alertStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statuses = (
|
|
||||||
<div className="ProposalMilestones-milestone-status">
|
|
||||||
{!milestone.immediatePayout && (
|
|
||||||
<div>
|
|
||||||
Estimate: <strong>{estimatedDate}</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
Reward: <strong>{reward}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<div className="ProposalMilestones-milestone">
|
|
||||||
<div className="ProposalMilestones-milestone-body">
|
|
||||||
<div className="ProposalMilestones-milestone-description">
|
|
||||||
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
|
||||||
{statuses}
|
|
||||||
{notification}
|
|
||||||
{milestone.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return { key: i, stepProps, content };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stepSize = milestoneCount > 5 ? 'small' : 'default';
|
const stepSize = milestoneCount > 5 ? 'small' : 'default';
|
||||||
|
const activeMilestone = proposal.milestones[this.state.step];
|
||||||
|
const activeIsCurrent = activeMilestone.id === proposal.currentMilestone!.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -198,7 +216,6 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
className={classnames({
|
className={classnames({
|
||||||
['ProposalMilestones']: true,
|
['ProposalMilestones']: true,
|
||||||
['do-titles-overflow']: this.state.doTitlesOverflow,
|
['do-titles-overflow']: this.state.doTitlesOverflow,
|
||||||
[`is-count-${milestoneCount}`]: true,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!!milestoneSteps.length ? (
|
{!!milestoneSteps.length ? (
|
||||||
|
@ -208,7 +225,22 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
<Steps.Step key={mss.key} {...mss.stepProps} />
|
<Steps.Step key={mss.key} {...mss.stepProps} />
|
||||||
))}
|
))}
|
||||||
</Steps>
|
</Steps>
|
||||||
{milestoneSteps[this.state.step].content}
|
<Milestone
|
||||||
|
isFunded={[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(
|
||||||
|
proposal.stage,
|
||||||
|
)}
|
||||||
|
proposalId={proposal.proposalId}
|
||||||
|
showRejectPayout={this.handleShowRejectPayout}
|
||||||
|
{...{ requestPayout, acceptPayout, rejectPayout }}
|
||||||
|
{...activeMilestone}
|
||||||
|
isCurrent={activeIsCurrent}
|
||||||
|
isTeamMember={proposal.isTeamMember || false}
|
||||||
|
isArbiter={proposal.isArbiter || false}
|
||||||
|
hasArbiter={
|
||||||
|
!!proposal.arbiter.user &&
|
||||||
|
proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Placeholder
|
<Placeholder
|
||||||
|
@ -216,21 +248,26 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
subtitle="The creator of this proposal has not setup any milestones"
|
subtitle="The creator of this proposal has not setup any milestones"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{rejectModal}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActiveMilestoneIdx = () => {
|
private handleShowRejectPayout = (milestoneId: number) => {
|
||||||
const { milestones } = this.props.proposal;
|
this.setState({ showRejectModal: true, rejectMilestoneId: milestoneId });
|
||||||
const activeMilestone =
|
// try to focus on text-area after modal loads
|
||||||
milestones.find(
|
setTimeout(() => {
|
||||||
m =>
|
if (this.rejectInput) this.rejectInput.focus();
|
||||||
m.state === WAITING ||
|
}, 200);
|
||||||
m.state === ACTIVE ||
|
};
|
||||||
(m.state === PAID && !m.isPaid) ||
|
|
||||||
m.state === REJECTED,
|
private handleReject = () => {
|
||||||
) || milestones[0];
|
const { proposalId } = this.props.proposal;
|
||||||
return milestones.indexOf(activeMilestone);
|
const { rejectMilestoneId, rejectReason } = this.state;
|
||||||
|
|
||||||
|
this.props.rejectPayout(proposalId, rejectMilestoneId, rejectReason);
|
||||||
|
|
||||||
|
this.setState({ showRejectModal: false, rejectMilestoneId: -1, rejectReason: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateDoTitlesOverflow = () => {
|
private updateDoTitlesOverflow = () => {
|
||||||
|
@ -268,11 +305,281 @@ class ProposalMilestones extends React.Component<Props, State> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectedProposalMilestones = connect((state: AppState) => {
|
// Milestone
|
||||||
console.warn('TODO - new redux accounts/user-role-for-proposal', state);
|
type MSProps = ProposalMilestone & DispatchProps;
|
||||||
return {
|
interface MilestoneProps extends MSProps {
|
||||||
accounts: [],
|
showRejectPayout: (milestoneId: number) => void;
|
||||||
};
|
isTeamMember: boolean;
|
||||||
})(ProposalMilestones);
|
isArbiter: boolean;
|
||||||
|
hasArbiter: boolean;
|
||||||
|
isCurrent: boolean;
|
||||||
|
proposalId: number;
|
||||||
|
isFunded: boolean;
|
||||||
|
}
|
||||||
|
const Milestone: React.SFC<MilestoneProps> = p => {
|
||||||
|
const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
|
||||||
|
const reward = <UnitDisplay value={p.amount} symbol="ZEC" displayShortBalance={4} />;
|
||||||
|
const getAlertProps = {
|
||||||
|
[MILESTONE_STAGE.IDLE]: () => null,
|
||||||
|
[MILESTONE_STAGE.REQUESTED]: () => ({
|
||||||
|
type: 'info',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
The team requested a payout for this milestone {fmtDateFromNow(p.dateRequested)}
|
||||||
|
. It is currently under review.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[MILESTONE_STAGE.REJECTED]: () => ({
|
||||||
|
type: 'warning',
|
||||||
|
message: (
|
||||||
|
<span>
|
||||||
|
Payout for this milestone was rejected {fmtDateFromNow(p.dateRejected)}.
|
||||||
|
{p.isTeamMember ? ' You ' : ' The team '} can request another review for payout
|
||||||
|
at any time.
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[MILESTONE_STAGE.ACCEPTED]: () => ({
|
||||||
|
type: 'info',
|
||||||
|
message: (
|
||||||
|
<span>
|
||||||
|
Payout for this milestone was accepted {fmtDateFromNow(p.dateAccepted)}.{' '}
|
||||||
|
<strong>{reward}</strong> will be sent to{' '}
|
||||||
|
{p.isTeamMember ? ' you ' : ' the team '} soon.
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[MILESTONE_STAGE.PAID]: () => ({
|
||||||
|
type: 'success',
|
||||||
|
message: (
|
||||||
|
<span>
|
||||||
|
The team was awarded <strong>{reward}</strong>{' '}
|
||||||
|
{p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}.
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
} as { [key in MILESTONE_STAGE]: () => AlertProps | null };
|
||||||
|
|
||||||
|
const alertProps = getAlertProps[p.stage]();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ProposalMilestones-milestone">
|
||||||
|
<div className="ProposalMilestones-milestone-body">
|
||||||
|
<div className="ProposalMilestones-milestone-description">
|
||||||
|
<h3 className="ProposalMilestones-milestone-title">{p.title}</h3>
|
||||||
|
<div className="ProposalMilestones-milestone-status">
|
||||||
|
{!p.immediatePayout && (
|
||||||
|
<div>
|
||||||
|
Estimate: <strong>{estimatedDate}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
Reward: <strong>{reward}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{alertProps && (
|
||||||
|
<Alert {...alertProps} className="ProposalMilestones-milestone-alert" />
|
||||||
|
)}
|
||||||
|
{p.content}
|
||||||
|
</div>
|
||||||
|
<MilestoneAction {...p} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MilestoneAction: React.SFC<MilestoneProps> = p => {
|
||||||
|
if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!p.hasArbiter && !p.isTeamMember) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEAM INFO
|
||||||
|
const team = {
|
||||||
|
[MILESTONE_STAGE.IDLE]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Payment Request</h3>
|
||||||
|
{p.immediatePayout && (
|
||||||
|
<p>
|
||||||
|
Congratulations on getting funded! You can now begin the process of receiving
|
||||||
|
your initial payment. Click below to request the first milestone payout. It
|
||||||
|
will instantly be approved, and you’ll receive your funds shortly thereafter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!p.immediatePayout &&
|
||||||
|
p.index === 0 && (
|
||||||
|
<p>
|
||||||
|
Congratulations on getting funded! Click below to request your first
|
||||||
|
milestone payout.
|
||||||
|
</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)} block>
|
||||||
|
{(p.immediatePayout && 'Request initial payout') || 'Request payout'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.REQUESTED]: () => (
|
||||||
|
<>
|
||||||
|
<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]: () => (
|
||||||
|
<>
|
||||||
|
<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]: () => (
|
||||||
|
<>
|
||||||
|
<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 };
|
||||||
|
|
||||||
|
// OUTSIDERS/OTHERS INFO
|
||||||
|
const others = {
|
||||||
|
[MILESTONE_STAGE.IDLE]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Payment Request</h3>
|
||||||
|
<p>The team may request a payout for this milestone at any time.</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.REQUESTED]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Payment Requested</h3>
|
||||||
|
<p>
|
||||||
|
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.REJECTED]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Payment Rejected</h3>
|
||||||
|
<p>
|
||||||
|
The payout request was denied on {fmtDate(p.dateRejected)} for the following
|
||||||
|
reason:
|
||||||
|
</p>
|
||||||
|
<q>{p.rejectReason}</q>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.ACCEPTED]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Awaiting Payment</h3>
|
||||||
|
<p>The payout request was approved on {fmtDate(p.dateAccepted)}.</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.PAID]: () => <></>,
|
||||||
|
} as { [key in MILESTONE_STAGE]: () => ReactNode };
|
||||||
|
|
||||||
|
// ARBITER INFO
|
||||||
|
const arbiter = {
|
||||||
|
[MILESTONE_STAGE.IDLE]: () => (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<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]: () => (
|
||||||
|
<>
|
||||||
|
<h3>Payment Rejected</h3>
|
||||||
|
<p>
|
||||||
|
You rejected this payment request on {fmtDate(p.dateRejected)} for the following
|
||||||
|
reason:
|
||||||
|
</p>
|
||||||
|
<q>{p.rejectReason}</q>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[MILESTONE_STAGE.ACCEPTED]: () => (
|
||||||
|
<>
|
||||||
|
<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 = null;
|
||||||
|
if (p.isTeamMember) {
|
||||||
|
content = team[p.stage]();
|
||||||
|
} else if (p.isArbiter) {
|
||||||
|
content = arbiter[p.stage]();
|
||||||
|
} else {
|
||||||
|
content = others[p.stage]();
|
||||||
|
}
|
||||||
|
|
||||||
|
// special warning if no arbiter is set for team members
|
||||||
|
if (!p.hasArbiter && p.isTeamMember) {
|
||||||
|
content = (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message="Arbiter not assigned"
|
||||||
|
description={
|
||||||
|
<p>
|
||||||
|
We are sorry for the inconvenience, but in order to have milestone payouts
|
||||||
|
reviewed an arbiter must be assigned. Please{' '}
|
||||||
|
<Link target="_blank" to="/contact">
|
||||||
|
contact support
|
||||||
|
</Link>{' '}
|
||||||
|
for help.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ProposalMilestones-milestone-divider" />
|
||||||
|
<div className="ProposalMilestones-milestone-action">{content}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConnectedProposalMilestones = connect<{}, DispatchProps, OwnProps, AppState>(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
requestPayout: proposalActions.requestPayout,
|
||||||
|
acceptPayout: proposalActions.acceptPayout,
|
||||||
|
rejectPayout: proposalActions.rejectPayout,
|
||||||
|
},
|
||||||
|
)(ProposalMilestones);
|
||||||
|
|
||||||
export default ConnectedProposalMilestones;
|
export default ConnectedProposalMilestones;
|
||||||
|
|
|
@ -1,10 +1,19 @@
|
||||||
import { ProposalDraft, CreateMilestone, STATUS, PROPOSAL_ARBITER_STATUS } from 'types';
|
import {
|
||||||
|
ProposalDraft,
|
||||||
|
CreateMilestone,
|
||||||
|
STATUS,
|
||||||
|
MILESTONE_STAGE,
|
||||||
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
} from 'types';
|
||||||
import { User } from 'types';
|
import { User } from 'types';
|
||||||
import { getAmountError, isValidAddress } from 'utils/validators';
|
import { getAmountError, isValidAddress } from 'utils/validators';
|
||||||
import { MILESTONE_STATE, Proposal } from 'types';
|
|
||||||
import { Zat, toZat } from 'utils/units';
|
import { Zat, toZat } from 'utils/units';
|
||||||
import { ONE_DAY } from 'utils/time';
|
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,
|
||||||
|
} from 'modules/proposals/reducers';
|
||||||
|
|
||||||
export const TARGET_ZEC_LIMIT = 1000;
|
export const TARGET_ZEC_LIMIT = 1000;
|
||||||
|
|
||||||
|
@ -170,7 +179,7 @@ export function proposalToContractData(form: ProposalDraft): any {
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is kind of a disgusting function, sorry.
|
// This is kind of a disgusting function, sorry.
|
||||||
export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDetail {
|
||||||
const { invites, ...rest } = draft;
|
const { invites, ...rest } = draft;
|
||||||
const target = parseFloat(draft.target);
|
const target = parseFloat(draft.target);
|
||||||
|
|
||||||
|
@ -189,22 +198,23 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
||||||
funded: Zat('0'),
|
funded: Zat('0'),
|
||||||
contributionMatching: 0,
|
contributionMatching: 0,
|
||||||
percentFunded: 0,
|
percentFunded: 0,
|
||||||
stage: 'preview',
|
stage: PROPOSAL_STAGE.PREVIEW,
|
||||||
category: draft.category || PROPOSAL_CATEGORY.CORE_DEV,
|
category: draft.category || PROPOSAL_CATEGORY.CORE_DEV,
|
||||||
isStaked: true,
|
isStaked: true,
|
||||||
arbiter: {
|
arbiter: {
|
||||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
},
|
},
|
||||||
milestones: draft.milestones.map((m, idx) => ({
|
milestones: draft.milestones.map((m, idx) => ({
|
||||||
|
id: idx,
|
||||||
index: idx,
|
index: idx,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
|
amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
|
||||||
dateEstimated: m.dateEstimated,
|
dateEstimated: m.dateEstimated,
|
||||||
immediatePayout: m.immediatePayout,
|
immediatePayout: m.immediatePayout,
|
||||||
isPaid: false,
|
|
||||||
payoutPercent: m.payoutPercent.toString(),
|
payoutPercent: m.payoutPercent.toString(),
|
||||||
state: MILESTONE_STATE.WAITING,
|
stage: MILESTONE_STAGE.IDLE,
|
||||||
})),
|
})),
|
||||||
|
...PROPOSAL_DETAIL_INITIAL_STATE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,9 @@ import {
|
||||||
getProposalUpdates,
|
getProposalUpdates,
|
||||||
getProposalContributions,
|
getProposalContributions,
|
||||||
postProposalComment as apiPostProposalComment,
|
postProposalComment as apiPostProposalComment,
|
||||||
|
requestProposalPayout,
|
||||||
|
acceptProposalPayout,
|
||||||
|
rejectProposalPayout,
|
||||||
} from 'api/api';
|
} from 'api/api';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { Proposal, Comment, ProposalPageParams } from 'types';
|
import { Proposal, Comment, ProposalPageParams } from 'types';
|
||||||
|
@ -14,6 +17,52 @@ import { getProposalPageSettings } from './selectors';
|
||||||
|
|
||||||
type GetState = () => AppState;
|
type GetState = () => AppState;
|
||||||
|
|
||||||
|
function addProposalUserRoles(p: Proposal, state: AppState) {
|
||||||
|
if (state.auth.user) {
|
||||||
|
const authUserId = state.auth.user.userid;
|
||||||
|
if (p.arbiter.user) {
|
||||||
|
p.isArbiter = p.arbiter.user.userid === authUserId;
|
||||||
|
}
|
||||||
|
if (p.team.find(t => t.userid === authUserId)) {
|
||||||
|
p.isTeamMember = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestPayout(proposalId: number, milestoneId: number) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
return dispatch({
|
||||||
|
type: types.PROPOSAL_PAYOUT_REQUEST,
|
||||||
|
payload: async () => {
|
||||||
|
return (await requestProposalPayout(proposalId, milestoneId)).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptPayout(proposalId: number, milestoneId: number) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
return dispatch({
|
||||||
|
type: types.PROPOSAL_PAYOUT_ACCEPT,
|
||||||
|
payload: async () => {
|
||||||
|
return (await acceptProposalPayout(proposalId, milestoneId)).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectPayout(proposalId: number, milestoneId: number, reason: string) {
|
||||||
|
return async (dispatch: Dispatch<any>) => {
|
||||||
|
return dispatch({
|
||||||
|
type: types.PROPOSAL_PAYOUT_REJECT,
|
||||||
|
payload: async () => {
|
||||||
|
return (await rejectProposalPayout(proposalId, milestoneId, reason)).data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// change page, sort, filter, search
|
// change page, sort, filter, search
|
||||||
export function setProposalPage(pageParams: Partial<ProposalPageParams>) {
|
export function setProposalPage(pageParams: Partial<ProposalPageParams>) {
|
||||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||||
|
@ -49,7 +98,7 @@ export function fetchProposals() {
|
||||||
|
|
||||||
export type TFetchProposal = typeof fetchProposal;
|
export type TFetchProposal = typeof fetchProposal;
|
||||||
export function fetchProposal(proposalId: Proposal['proposalId']) {
|
export function fetchProposal(proposalId: Proposal['proposalId']) {
|
||||||
return async (dispatch: Dispatch<any>) => {
|
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.PROPOSAL_DATA_PENDING,
|
type: types.PROPOSAL_DATA_PENDING,
|
||||||
payload: { proposalId },
|
payload: { proposalId },
|
||||||
|
@ -58,7 +107,7 @@ export function fetchProposal(proposalId: Proposal['proposalId']) {
|
||||||
const proposal = (await getProposal(proposalId)).data;
|
const proposal = (await getProposal(proposalId)).data;
|
||||||
return dispatch({
|
return dispatch({
|
||||||
type: types.PROPOSAL_DATA_FULFILLED,
|
type: types.PROPOSAL_DATA_FULFILLED,
|
||||||
payload: proposal,
|
payload: addProposalUserRoles(proposal, getState()),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -10,10 +10,19 @@ import {
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import { PROPOSAL_SORT } from 'api/constants';
|
import { PROPOSAL_SORT } from 'api/constants';
|
||||||
|
|
||||||
|
export interface ProposalDetail extends Proposal {
|
||||||
|
isRequestingPayout: boolean;
|
||||||
|
requestPayoutError: string;
|
||||||
|
isRejectingPayout: boolean;
|
||||||
|
rejectPayoutError: string;
|
||||||
|
isAcceptingPayout: boolean;
|
||||||
|
acceptPayoutError: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProposalState {
|
export interface ProposalState {
|
||||||
page: LoadableProposalPage;
|
page: LoadableProposalPage;
|
||||||
|
|
||||||
detail: null | Proposal;
|
detail: null | ProposalDetail;
|
||||||
isFetchingDetail: boolean;
|
isFetchingDetail: boolean;
|
||||||
detailError: null | string;
|
detailError: null | string;
|
||||||
|
|
||||||
|
@ -36,6 +45,15 @@ export interface ProposalState {
|
||||||
deleteContributionError: null | string;
|
deleteContributionError: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PROPOSAL_DETAIL_INITIAL_STATE = {
|
||||||
|
isRequestingPayout: false,
|
||||||
|
requestPayoutError: '',
|
||||||
|
isRejectingPayout: false,
|
||||||
|
rejectPayoutError: '',
|
||||||
|
isAcceptingPayout: false,
|
||||||
|
acceptPayoutError: '',
|
||||||
|
};
|
||||||
|
|
||||||
export const INITIAL_STATE: ProposalState = {
|
export const INITIAL_STATE: ProposalState = {
|
||||||
page: {
|
page: {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
@ -203,14 +221,14 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
// if requesting same proposal, leave the detail object
|
// if requesting same proposal, leave the detail object
|
||||||
state.detail && state.detail.proposalId === payload.proposalId
|
state.detail && state.detail.proposalId === payload.proposalId
|
||||||
? state.detail
|
? state.detail
|
||||||
: loadedInPage || null,
|
: { ...loadedInPage, ...PROPOSAL_DETAIL_INITIAL_STATE } || null,
|
||||||
isFetchingDetail: true,
|
isFetchingDetail: true,
|
||||||
detailError: null,
|
detailError: null,
|
||||||
};
|
};
|
||||||
case types.PROPOSAL_DATA_FULFILLED:
|
case types.PROPOSAL_DATA_FULFILLED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
detail: payload,
|
detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE },
|
||||||
isFetchingDetail: false,
|
isFetchingDetail: false,
|
||||||
};
|
};
|
||||||
case types.PROPOSAL_DATA_REJECTED:
|
case types.PROPOSAL_DATA_REJECTED:
|
||||||
|
@ -221,6 +239,78 @@ export default (state = INITIAL_STATE, action: any) => {
|
||||||
detailError: (payload && payload.message) || payload.toString(),
|
detailError: (payload && payload.message) || payload.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case types.PROPOSAL_PAYOUT_REQUEST_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isRequestingPayout: true,
|
||||||
|
requestPayoutError: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_REQUEST_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE },
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_REQUEST_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isRequestingPayout: false,
|
||||||
|
requestPayoutError: (payload && payload.message) || payload.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case types.PROPOSAL_PAYOUT_REJECT_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isRejectingPayout: true,
|
||||||
|
rejectPayoutError: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_REJECT_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE },
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_REJECT_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isRejectingPayout: false,
|
||||||
|
rejectPayoutError: (payload && payload.message) || payload.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case types.PROPOSAL_PAYOUT_ACCEPT_PENDING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isAcceptingPayout: true,
|
||||||
|
acceptPayoutError: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_ACCEPT_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE },
|
||||||
|
};
|
||||||
|
case types.PROPOSAL_PAYOUT_ACCEPT_REJECTED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
detail: {
|
||||||
|
...state.detail,
|
||||||
|
isAcceptingPayout: false,
|
||||||
|
acceptPayoutError: (payload && payload.message) || payload.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case types.PROPOSAL_COMMENTS_PENDING:
|
case types.PROPOSAL_COMMENTS_PENDING:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -32,6 +32,21 @@ enum proposalTypes {
|
||||||
POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION',
|
POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION',
|
||||||
|
|
||||||
SET_PROPOSAL_PAGE = 'SET_PROPOSAL_PAGE',
|
SET_PROPOSAL_PAGE = 'SET_PROPOSAL_PAGE',
|
||||||
|
|
||||||
|
PROPOSAL_PAYOUT_REQUEST = 'PROPOSAL_PAYOUT_REQUEST',
|
||||||
|
PROPOSAL_PAYOUT_REQUEST_FULFILLED = 'PROPOSAL_PAYOUT_REQUEST_FULFILLED',
|
||||||
|
PROPOSAL_PAYOUT_REQUEST_REJECTED = 'PROPOSAL_PAYOUT_REQUEST_REJECTED',
|
||||||
|
PROPOSAL_PAYOUT_REQUEST_PENDING = 'PROPOSAL_PAYOUT_REQUEST_PENDING',
|
||||||
|
|
||||||
|
PROPOSAL_PAYOUT_REJECT = 'PROPOSAL_PAYOUT_REJECT',
|
||||||
|
PROPOSAL_PAYOUT_REJECT_FULFILLED = 'PROPOSAL_PAYOUT_REJECT_FULFILLED',
|
||||||
|
PROPOSAL_PAYOUT_REJECT_REJECTED = 'PROPOSAL_PAYOUT_REJECT_REJECTED',
|
||||||
|
PROPOSAL_PAYOUT_REJECT_PENDING = 'PROPOSAL_PAYOUT_REJECT_PENDING',
|
||||||
|
|
||||||
|
PROPOSAL_PAYOUT_ACCEPT = 'PROPOSAL_PAYOUT_ACCEPT',
|
||||||
|
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
|
||||||
|
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
|
||||||
|
PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default proposalTypes;
|
export default proposalTypes;
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
PageParams,
|
PageParams,
|
||||||
UserProposal,
|
UserProposal,
|
||||||
RFP,
|
RFP,
|
||||||
MILESTONE_STATE,
|
|
||||||
ProposalPage,
|
ProposalPage,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import { UserState } from 'modules/users/reducers';
|
import { UserState } from 'modules/users/reducers';
|
||||||
|
@ -91,16 +90,12 @@ export function formatProposalFromGet(p: any): Proposal {
|
||||||
? 0
|
? 0
|
||||||
: proposal.funded.div(proposal.target.divn(100)).toNumber();
|
: proposal.funded.div(proposal.target.divn(100)).toNumber();
|
||||||
if (proposal.milestones) {
|
if (proposal.milestones) {
|
||||||
proposal.milestones = proposal.milestones.map((m: any, index: number) => {
|
const msToFe = (m: any) => ({
|
||||||
return {
|
...m,
|
||||||
...m,
|
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
||||||
index,
|
|
||||||
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
|
||||||
// TODO: Get data from backend
|
|
||||||
state: MILESTONE_STATE.WAITING,
|
|
||||||
isPaid: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
proposal.milestones = proposal.milestones.map(msToFe);
|
||||||
|
proposal.currentMilestone = msToFe(proposal.currentMilestone);
|
||||||
}
|
}
|
||||||
return proposal;
|
return proposal;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,25 +5,25 @@ import { Provider } from 'react-redux';
|
||||||
import { configureStore } from 'store/configure';
|
import { configureStore } from 'store/configure';
|
||||||
import { combineInitialState } from 'store/reducers';
|
import { combineInitialState } from 'store/reducers';
|
||||||
import Milestones from 'components/Proposal/Milestones';
|
import Milestones from 'components/Proposal/Milestones';
|
||||||
import { MILESTONE_STATE } from 'types';
|
import { MILESTONE_STAGE } from 'types';
|
||||||
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
|
const { IDLE, ACCEPTED, PAID, REJECTED } = MILESTONE_STAGE;
|
||||||
|
|
||||||
import 'styles/style.less';
|
import 'styles/style.less';
|
||||||
import 'components/Proposal/style.less';
|
import 'components/Proposal/style.less';
|
||||||
import 'components/Proposal/Governance/style.less';
|
import 'components/Proposal/Governance/style.less';
|
||||||
import { generateProposal } from './props';
|
import { generateProposal } from './props';
|
||||||
|
|
||||||
const msWaiting = { state: WAITING, isPaid: false };
|
const msWaiting = { stage: IDLE };
|
||||||
const msPaid = { state: PAID, isPaid: true };
|
const msPaid = { stage: PAID };
|
||||||
const msActive = { state: ACTIVE, isPaid: false };
|
const msActive = { stage: ACCEPTED };
|
||||||
const msRejected = { state: REJECTED, isPaid: false };
|
const msRejected = { stage: REJECTED };
|
||||||
|
|
||||||
const trustee = 'z123';
|
const trustee = 'z123';
|
||||||
const contributor = 'z456';
|
const contributor = 'z456';
|
||||||
|
|
||||||
const geometryCases = [...Array(10).keys()].map(i =>
|
// const geometryCases = [...Array(10).keys()].map(i =>
|
||||||
generateProposal({ milestoneCount: i + 1 }),
|
// generateProposal({ milestoneCount: i + 1 }),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const cases: { [index: string]: any } = {
|
const cases: { [index: string]: any } = {
|
||||||
// trustee - first
|
// trustee - first
|
||||||
|
@ -38,11 +38,7 @@ const cases: { [index: string]: any } = {
|
||||||
['first - not paid']: generateProposal({
|
['first - not paid']: generateProposal({
|
||||||
amount: 5,
|
amount: 5,
|
||||||
funded: 5,
|
funded: 5,
|
||||||
milestoneOverrides: [
|
milestoneOverrides: [{ stage: PAID }, msWaiting, msWaiting],
|
||||||
{ state: PAID, isPaid: false },
|
|
||||||
msWaiting,
|
|
||||||
msWaiting,
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// trustee - second
|
// trustee - second
|
||||||
|
@ -59,20 +55,12 @@ const cases: { [index: string]: any } = {
|
||||||
['second - not paid']: generateProposal({
|
['second - not paid']: generateProposal({
|
||||||
amount: 5,
|
amount: 5,
|
||||||
funded: 5,
|
funded: 5,
|
||||||
milestoneOverrides: [
|
milestoneOverrides: [msPaid, { stage: PAID }, msWaiting],
|
||||||
msPaid,
|
|
||||||
{ state: PAID, isPaid: false },
|
|
||||||
msWaiting,
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
['second - no vote']: generateProposal({
|
['second - no vote']: generateProposal({
|
||||||
amount: 5,
|
amount: 5,
|
||||||
funded: 5,
|
funded: 5,
|
||||||
milestoneOverrides: [
|
milestoneOverrides: [msPaid, { stage: ACCEPTED }, msWaiting],
|
||||||
msPaid,
|
|
||||||
{ state: ACTIVE, isPaid: false },
|
|
||||||
msWaiting,
|
|
||||||
],
|
|
||||||
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
|
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
|
||||||
}),
|
}),
|
||||||
['second - rejected']: generateProposal({
|
['second - rejected']: generateProposal({
|
||||||
|
@ -95,20 +83,12 @@ const cases: { [index: string]: any } = {
|
||||||
['final - not paid']: generateProposal({
|
['final - not paid']: generateProposal({
|
||||||
amount: 5,
|
amount: 5,
|
||||||
funded: 5,
|
funded: 5,
|
||||||
milestoneOverrides: [
|
milestoneOverrides: [msPaid, msPaid, { stage: PAID }],
|
||||||
msPaid,
|
|
||||||
msPaid,
|
|
||||||
{ state: PAID, isPaid: false },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
['final - no vote']: generateProposal({
|
['final - no vote']: generateProposal({
|
||||||
amount: 5,
|
amount: 5,
|
||||||
funded: 5,
|
funded: 5,
|
||||||
milestoneOverrides: [
|
milestoneOverrides: [msPaid, msPaid, { stage: ACCEPTED }],
|
||||||
msPaid,
|
|
||||||
msPaid,
|
|
||||||
{ state: ACTIVE, isPaid: false },
|
|
||||||
],
|
|
||||||
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
|
contributorOverrides: [{ milestoneNoVotes: [false, true, false] }],
|
||||||
}),
|
}),
|
||||||
['final - rejected']: generateProposal({
|
['final - rejected']: generateProposal({
|
||||||
|
@ -169,14 +149,14 @@ for (const key of Object.keys(cases)) {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometryStories = storiesOf('Proposal/Milestones/geometry', module);
|
// const geometryStories = storiesOf('Proposal/Milestones/geometry', module);
|
||||||
|
|
||||||
geometryCases.forEach((gc, idx) =>
|
// geometryCases.forEach((gc, idx) =>
|
||||||
geometryStories.add(`${idx + 1} steps`, () => (
|
// geometryStories.add(`${idx + 1} steps`, () => (
|
||||||
<div key={idx} style={{ padding: '3em', display: 'flex' }}>
|
// <div key={idx} style={{ padding: '3em', display: 'flex' }}>
|
||||||
<Provider store={storeOutsider}>
|
// <Provider store={storeOutsider}>
|
||||||
<Milestones {...gc} />
|
// <Milestones {...gc} />
|
||||||
</Provider>
|
// </Provider>
|
||||||
</div>
|
// </div>
|
||||||
)),
|
// )),
|
||||||
);
|
// );
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import {
|
import {
|
||||||
Contributor,
|
Contributor,
|
||||||
Milestone,
|
MILESTONE_STAGE,
|
||||||
MILESTONE_STATE,
|
|
||||||
Proposal,
|
Proposal,
|
||||||
ProposalMilestone,
|
ProposalMilestone,
|
||||||
STATUS,
|
STATUS,
|
||||||
PROPOSAL_ARBITER_STATUS,
|
PROPOSAL_ARBITER_STATUS,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants';
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
|
@ -41,7 +40,7 @@ export function generateProposal({
|
||||||
funded?: number;
|
funded?: number;
|
||||||
created?: number;
|
created?: number;
|
||||||
deadline?: number;
|
deadline?: number;
|
||||||
milestoneOverrides?: Array<Partial<Milestone>>;
|
milestoneOverrides?: Array<Partial<ProposalMilestone>>;
|
||||||
contributorOverrides?: Array<Partial<Contributor>>;
|
contributorOverrides?: Array<Partial<Contributor>>;
|
||||||
milestoneCount?: number;
|
milestoneCount?: number;
|
||||||
}) {
|
}) {
|
||||||
|
@ -110,15 +109,15 @@ export function generateProposal({
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: ProposalMilestone = {
|
const defaults: ProposalMilestone = {
|
||||||
|
id: 0,
|
||||||
title: 'Milestone A',
|
title: 'Milestone A',
|
||||||
content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||||
tempor incididunt ut labore et dolore magna aliqua.`,
|
tempor incididunt ut labore et dolore magna aliqua.`,
|
||||||
dateEstimated: moment().unix(),
|
dateEstimated: moment().unix(),
|
||||||
immediatePayout: true,
|
immediatePayout: true,
|
||||||
index: 0,
|
index: 0,
|
||||||
state: MILESTONE_STATE.WAITING,
|
stage: MILESTONE_STAGE.IDLE,
|
||||||
amount: amountBn,
|
amount: amountBn,
|
||||||
isPaid: false,
|
|
||||||
payoutPercent: '33',
|
payoutPercent: '33',
|
||||||
};
|
};
|
||||||
return { ...defaults, ...overrides };
|
return { ...defaults, ...overrides };
|
||||||
|
@ -126,6 +125,7 @@ export function generateProposal({
|
||||||
|
|
||||||
const milestones = [...Array(milestoneCount).keys()].map(i => {
|
const milestones = [...Array(milestoneCount).keys()].map(i => {
|
||||||
const overrides = {
|
const overrides = {
|
||||||
|
id: i,
|
||||||
index: i,
|
index: i,
|
||||||
title: genMilestoneTitle(),
|
title: genMilestoneTitle(),
|
||||||
immediatePayout: i === 0,
|
immediatePayout: i === 0,
|
||||||
|
@ -158,7 +158,7 @@ export function generateProposal({
|
||||||
title: 'Crowdfund Title',
|
title: 'Crowdfund Title',
|
||||||
brief: 'A cool test crowdfund',
|
brief: 'A cool test crowdfund',
|
||||||
content: 'body',
|
content: 'body',
|
||||||
stage: 'FUNDING_REQUIRED',
|
stage: PROPOSAL_STAGE.WIP,
|
||||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||||
isStaked: true,
|
isStaked: true,
|
||||||
arbiter: {
|
arbiter: {
|
||||||
|
|
|
@ -7,16 +7,31 @@ export enum MILESTONE_STATE {
|
||||||
PAID = 'PAID',
|
PAID = 'PAID',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: sync with /backend/grand/utils/enums.py MilestoneStage
|
||||||
|
export enum MILESTONE_STAGE {
|
||||||
|
IDLE = 'IDLE',
|
||||||
|
REQUESTED = 'REQUESTED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
PAID = 'PAID',
|
||||||
|
}
|
||||||
|
|
||||||
export interface Milestone {
|
export interface Milestone {
|
||||||
index: number;
|
index: number;
|
||||||
state: MILESTONE_STATE;
|
stage: MILESTONE_STAGE;
|
||||||
amount: Zat;
|
amount: Zat;
|
||||||
isPaid: boolean;
|
|
||||||
immediatePayout: boolean;
|
immediatePayout: boolean;
|
||||||
dateEstimated: number;
|
dateEstimated: number;
|
||||||
|
dateRequested?: number;
|
||||||
|
dateRejected?: number;
|
||||||
|
dateAccepted?: number;
|
||||||
|
datePaid?: number;
|
||||||
|
rejectReason?: string;
|
||||||
|
paidTxId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProposalMilestone extends Milestone {
|
export interface ProposalMilestone extends Milestone {
|
||||||
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
payoutPercent: string;
|
payoutPercent: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Zat } from 'utils/units';
|
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 { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
|
||||||
import { ProposalMilestone } from './milestone';
|
import { ProposalMilestone } from './milestone';
|
||||||
import { RFP } from './rfp';
|
import { RFP } from './rfp';
|
||||||
|
@ -36,7 +36,7 @@ export interface ProposalDraft {
|
||||||
brief: string;
|
brief: string;
|
||||||
category: PROPOSAL_CATEGORY;
|
category: PROPOSAL_CATEGORY;
|
||||||
content: string;
|
content: string;
|
||||||
stage: string;
|
stage: PROPOSAL_STAGE;
|
||||||
target: string;
|
target: string;
|
||||||
payoutAddress: string;
|
payoutAddress: string;
|
||||||
deadlineDuration: number;
|
deadlineDuration: number;
|
||||||
|
@ -56,9 +56,12 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
percentFunded: number;
|
percentFunded: number;
|
||||||
contributionMatching: number;
|
contributionMatching: number;
|
||||||
milestones: ProposalMilestone[];
|
milestones: ProposalMilestone[];
|
||||||
|
currentMilestone?: ProposalMilestone;
|
||||||
datePublished: number | null;
|
datePublished: number | null;
|
||||||
dateApproved: number | null;
|
dateApproved: number | null;
|
||||||
arbiter: ProposalProposalArbiter;
|
arbiter: ProposalProposalArbiter;
|
||||||
|
isTeamMember?: boolean; // FE derived
|
||||||
|
isArbiter?: boolean; // FE derived
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamInviteWithProposal extends TeamInvite {
|
export interface TeamInviteWithProposal extends TeamInvite {
|
||||||
|
|
Loading…
Reference in New Issue