diff --git a/.template-lintrc.js b/.template-lintrc.js index 5e45040..4d151e0 100644 --- a/.template-lintrc.js +++ b/.template-lintrc.js @@ -5,7 +5,8 @@ module.exports = { rules: { 'simple-unless': false, - 'no-nested-interactive': false + 'no-nested-interactive': false, + 'no-html-comments': false }, ignore: [ diff --git a/app/components/add-contribution/component.js b/app/components/add-contribution/component.js index 56de353..96c4f48 100644 --- a/app/components/add-contribution/component.js +++ b/app/components/add-contribution/component.js @@ -1,13 +1,15 @@ import Component from '@ember/component'; import { computed } from '@ember/object'; -import { and, notEmpty } from '@ember/object/computed'; +import { alias, and, notEmpty } from '@ember/object/computed'; import { assign } from '@ember/polyfills'; import moment from 'moment'; +import { inject as service } from '@ember/service'; export default Component.extend({ + kredits: service(), attributes: null, - contributors: Object.freeze([]), + contributors: alias('kredits.contributorsSorted'), isValidContributor: notEmpty('contributorId'), isValidKind: notEmpty('kind'), @@ -72,7 +74,5 @@ export default Component.extend({ }) .finally(() => this.set('inProgress', false)); } - } - }); diff --git a/app/components/add-expense-item/component.js b/app/components/add-expense-item/component.js new file mode 100644 index 0000000..66512fa --- /dev/null +++ b/app/components/add-expense-item/component.js @@ -0,0 +1,86 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import moment from 'moment'; +import isValidAmount from 'kredits-web/utils/is-valid-amount'; +import { isPresent } from '@ember/utils'; + +export default class AddExpenseItemComponent extends Component { + @tracked amount = '0'; + @tracked currency = 'EUR'; + @tracked date = moment().startOf('hour').toDate(); + @tracked title = ''; + @tracked description = ''; + @tracked url = ''; + @tracked tags = ''; + + defaultDate = moment().startOf('hour').toDate(); + + currencies = [ + { code: 'EUR' }, + { code: 'USD' } + ]; + + get isValidAmount () { + return isValidAmount(this.amount); + } + + get amountInputClass () { + return this.isValidTotal ? 'valid' : ''; + } + + validateForm () { + const formEl = document.querySelector('form#add-expense-item'); + const inputFields = formEl.querySelectorAll('input'); + inputFields.forEach(i => i.classList.remove('invalid')); + let validity = true; + + if (!this.isValidAmount) { + document.querySelector('input[name=expense-amount]').classList.add('invalid'); + validity = false; + } + + if (!formEl.checkValidity()) { + inputFields.forEach(i => { + if (!i.validity.valid) { + i.classList.add('invalid'); + validity = false; + } + }) + } + + return validity; + } + + @action + updateCurrency(event) { + this.currency = event.target.value; + } + + @action + submit (e) { + e.preventDefault(); + + let dateInput = (this.date instanceof Array) ? + this.date[0] : this.date; + const [ date ] = dateInput.toISOString().split('T'); + + const isValid = this.validateForm(); + if (!isValid) return false; + + const expense = { + amount: parseFloat(this.amount), + currency: this.currency, + date: date, + title: this.title, + description: isPresent(this.description) ? this.description : undefined, + url: isPresent(this.url) ? this.url : undefined, + } + + if (isPresent(this.tags)) { + expense.tags = this.tags.split(',').map(t => t.trim()); + } + + this.args.addExpenseItem(expense); + } +} diff --git a/app/components/add-expense-item/template.hbs b/app/components/add-expense-item/template.hbs new file mode 100644 index 0000000..cb2bb13 --- /dev/null +++ b/app/components/add-expense-item/template.hbs @@ -0,0 +1,69 @@ +
+
+ + +
+ + + + + + +

+ +

+
\ No newline at end of file diff --git a/app/components/add-reimbursement/component.js b/app/components/add-reimbursement/component.js new file mode 100644 index 0000000..ca2c5a4 --- /dev/null +++ b/app/components/add-reimbursement/component.js @@ -0,0 +1,147 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { alias } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { A } from '@ember/array'; +import { scheduleOnce } from '@ember/runloop'; +import isValidAmount from 'kredits-web/utils/is-valid-amount'; +import config from 'kredits-web/config/environment'; + +export default class AddReimbursementComponent extends Component { + @service router; + @service kredits; + @service exchangeRates; + + @alias('kredits.contributorsSorted') contributors; + + @tracked contributorId = null; + @tracked title = ''; + @tracked total = '0'; + @tracked expenses = A([]); + @tracked expenseFormVisible = true; + + constructor() { + super(...arguments); + this.exchangeRates.fetchRates(); + } + + get isValidTotal () { + return isValidAmount(this.total); + } + + get totalInputClass () { + return this.isValidTotal ? 'valid' : ''; + } + + get totalEUR () { + const expenses = this.expenses.filterBy('currency', 'EUR'); + if (expenses.length > 0) { + return expenses.mapBy('amount') + .reduce((summation, current) => summation + current); + } else { + return 0; + } + } + + get totalUSD () { + const expenses = this.expenses.filterBy('currency', 'USD'); + if (expenses.length > 0) { + return expenses.mapBy('amount') + .reduce((summation, current) => summation + current); + } else { + return 0; + } + } + + get submitButtonEnabled () { + return this.isValidTotal && + (this.expenses.length > 0); + } + + get submitButtonDisabled () { + return !this.submitButtonEnabled; + } + + scrollToExpenseItemForm () { + const anchor = document.getElementById('new-expense-item'); + anchor.scrollIntoView(); + } + + updateTotalAmountFromFiat() { + let btcAmount = parseFloat(this.total); + + if (this.exchangeRates.btceur > 0 && this.totalEUR > 0) { + btcAmount += (this.totalEUR / this.exchangeRates.btceur); + } + if (this.exchangeRates.btcusd > 0 && this.totalUSD > 0) { + btcAmount += (this.totalUSD / this.exchangeRates.btcusd); + } + if (this.totalUSD === 0 && this.totalEUR === 0) { + btcAmount = 0; + } + + this.total = btcAmount.toFixed(8); + } + + @action + updateContributor(event) { + this.contributorId = event.target.value; + } + + @action + showExpenseForm () { + this.expenseFormVisible = true; + scheduleOnce('afterRender', this, this.scrollToExpenseItemForm); + } + + @action + addExpenseItem (expenseItem) { + this.expenses.pushObject(expenseItem); + this.updateTotalAmountFromFiat(); + this.expenseFormVisible = false; + } + + @action + removeExpenseItem (expenseItem) { + this.expenses.removeObject(expenseItem); + this.updateTotalAmountFromFiat(); + + if (this.expenses.length === 0) { + this.expenseFormVisible = true; + } + } + + @action + submit (e) { + e.preventDefault(); + if (!this.kredits.currentUser) { window.alert('You need to connect your Ethereum account first.'); return false } + if (!this.kredits.currentUserIsCore) { window.alert('Only core contributors can submit reimbursements.'); return false } + + const contributor = this.contributors.findBy('id', this.contributorId); + + const attributes = { + amount: parseInt(parseFloat(this.total) * 100000000), // convert to sats + token: config.tokens['WBTC'], + contributorId: parseInt(this.contributorId), + title: `Expenses covered by ${contributor.name}`, + description: this.description, + url: this.url, + expenses: JSON.parse(JSON.stringify((this.expenses))) + } + + this.inProgress = true; + + this.kredits.addReimbursement(attributes) + .then((/* reimbursement */) => { + this.router.transitionTo('budget'); + }) + .catch(e => { + console.error('Could not add reimbursement:', e); + window.alert('Something went wrong. Please check the browser console.') + }) + .finally(() => { + this.inProgress = false; + }); + } +} diff --git a/app/components/add-reimbursement/template.hbs b/app/components/add-reimbursement/template.hbs new file mode 100644 index 0000000..7360686 --- /dev/null +++ b/app/components/add-reimbursement/template.hbs @@ -0,0 +1,89 @@ +
+ +
+ + + +
+ +

Expense items

+ {{#if this.expenses}} + +

+ +

+ {{else}} +

No line items yet.

+ {{/if}} + +

+ {{#if this.inProgress}} + + {{else}} + + {{/if}} +

+ + {{#if this.expenseFormVisible}} +

New expense item

+ + {{/if}} + diff --git a/app/components/budget-balances/component.js b/app/components/budget-balances/component.js new file mode 100644 index 0000000..7ae86bd --- /dev/null +++ b/app/components/budget-balances/component.js @@ -0,0 +1,12 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; + +export default class BudgetBalancesComponent extends Component { + @service communityFunds + @alias('communityFunds.balances') balances; + + get loading () { + return !this.communityFunds.balancesLoaded; + } +} diff --git a/app/components/budget-balances/template.hbs b/app/components/budget-balances/template.hbs new file mode 100644 index 0000000..d8a7de3 --- /dev/null +++ b/app/components/budget-balances/template.hbs @@ -0,0 +1,18 @@ + + + + + + + + + + {{#each this.balances as |balance|}} + + + + + + {{/each}} + +
TokenAmountFiat value
{{balance.token.symbol}}{{fmt-crypto-currency balance.balance balance.token.symbol}}~{{balance.balanceUsd}} USD
\ No newline at end of file diff --git a/app/components/contribution-list/template.hbs b/app/components/contribution-list/template.hbs index 42280a2..be603d0 100644 --- a/app/components/contribution-list/template.hbs +++ b/app/components/contribution-list/template.hbs @@ -29,11 +29,11 @@ {{/if}} -