Skip to content

Commit

Permalink
#1704 - Add 'Export to CSV' feature to payments table (#1724)
Browse files Browse the repository at this point in the history
* add export CSV button to the table

* create ExportPaymentsModal.vue and register it

* make sure all the UI side of work are ready

* complete CSV Extraction logic

* complete the download CSV file logic

* fix the linter error

* work on Greg's feedback on the button position

* make sure 'Export CSV' button is not overlapped regardless of the screen width

* fix the linter error

* put all-period option into the dropdown

* remove the unecessary extra padding

* DRY PaymentsMixin.js

* update for Greg's CR on the PR

* fix the broken translation o the modal title
  • Loading branch information
SebinSong authored Oct 5, 2023
1 parent d537fdb commit f60b035
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 35 deletions.
11 changes: 11 additions & 0 deletions frontend/assets/style/components/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ legend.legend {
padding-left: 1rem;
min-width: 6.5rem;

&:disabled {
cursor: default;
color: $text_1;
opacity: 0.7;

&:hover,
&:focus {
border-color: $general_0;
}
}

option:disabled,
&.is-empty {
color: $text_1;
Expand Down
1 change: 1 addition & 0 deletions frontend/utils/lazyLoadedView.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ lazyModalFullScreen('PropositionsAllModal', () => import('../views/containers/pr
lazyComponent('PaymentDetail', () => import('../views/containers/payments/PaymentDetail.vue'))
lazyComponent('RecordPayment', () => import('../views/containers/payments/RecordPayment.vue'))
lazyComponent('SendPaymentsViaLightning', () => import('../views/containers/payments/SendPaymentsViaLightning.vue'))
lazyComponent('ExportPaymentsModal', () => import('../views/containers/payments/ExportPaymentsModal.vue'))

lazyComponent('Appearence', () => import('../views/containers/user-settings/Appearence.vue'))
lazyComponent('NotificationSettings', () => import('../views/containers/user-settings/NotificationSettings.vue'))
Expand Down
195 changes: 195 additions & 0 deletions frontend/views/containers/payments/ExportPaymentsModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<template lang='pug'>
modal-template(ref='modal' :a11yTitle='modalTitle')
template(slot='title')
span {{ modalTitle }}

.c-sub-title.has-text-1 {{ exportInstructions }}

label.field
.label
i18n Payment period

.selectbox
select.select.c-period-select(
name='period'
required=''
v-model='form.period'
:class='{ "is-empty": form.period === "choose" }'
)
option(
value='choose'
:disabled='true'
)
i18n Select payment period

option(value='all')
i18n Export all periods

option(
v-for='period in ephemeral.periodOpts'
:key='period'
:value='period'
) {{ displayPeriod(period) }}

.buttons.c-btns-container
i18n.is-outlined(
tag='button'
type='button'
@click='close'
) Cancel

i18n(
tag='button'
@click='exportToCSV'
:disabled='form.period === "choose"'
) Export payments

a.c-invisible-download-helper(
ref='downloadHelper'
:download='ephemeral.downloadName'
)
</template>

<script>
import { mapGetters } from 'vuex'
import ModalTemplate from '@components/modal/ModalTemplate.vue'
import { uniq } from '@model/contracts/shared/giLodash.js'
import { humanDate } from '@model/contracts/shared/time.js'
import { L } from '@common/common.js'
export default ({
name: 'ExportPaymentsModal',
components: {
ModalTemplate
},
data () {
return {
form: {
period: 'choose'
},
ephemeral: {
periodOpts: [],
downloadUrl: '',
downloadName: ''
}
}
},
props: {
data: Array
},
computed: {
...mapGetters([
'userDisplayName',
'withGroupCurrency'
]),
paymentType () {
return this.$route.query.type
},
modalTitle () {
return this.paymentType === 'sent'
? L('Export sent payments')
: L('Export received payments')
},
exportInstructions () {
return this.paymentType === 'sent'
? L('Export your sent payment history to .csv')
: L('Export your received payment history to .csv')
}
},
methods: {
displayPeriod (period) {
return humanDate(period, { month: 'short', day: 'numeric', year: 'numeric' })
},
close () {
this.$refs.modal.close()
},
exportToCSV () {
// logic here is inspired from the article below:
// https://medium.com/@idorenyinudoh10/how-to-export-data-from-javascript-to-a-csv-file-955bdfc394a9
const itemsToExport = this.form.period === 'all'
? this.data
: this.data.filter(
entry => entry.period === this.form.period
)
const tableHeadings = [
this.paymentType === 'sent' ? L('Sent to') : L('Sent by'),
L('Amount'),
L('Payment method'),
L('Date & Time'),
L('Period'),
L('Mincome at the time')
]
const tableRows = itemsToExport.map(entry => {
return [
this.paymentType === 'sent'
? this.userDisplayName(entry.data.toUser)
: this.userDisplayName(entry.meta.username), // 'Sent by' or 'Sent to'
this.withGroupCurrency(entry.data.amount), // 'Amount',
L('Manual'), // 'Payment metod' - !!TODO: once lightning payment is implemented in the app, update the logic here too.
humanDate(
entry.meta.createdDate,
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
).replaceAll(',', ''), // 'Date & Time'
humanDate(
entry.period,
{ month: 'long', year: 'numeric', day: 'numeric' }
).replaceAll(',', ''), // 'Period'
this.withGroupCurrency(entry.data.groupMincome) // Mincome at the time
]
})
let csvContent = tableHeadings.join(',') + '\r\n'
for (const row of tableRows) {
csvContent += row.join(',') + '\r\n'
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8,' })
const downloadUrl = URL.createObjectURL(blob)
this.ephemeral.downloadName = `${this.paymentType === 'sent' ? L('Sent') : L('Received')} payments.csv`
this.$refs.downloadHelper.setAttribute('href', downloadUrl)
this.$nextTick(() => {
this.$refs.downloadHelper.click()
})
}
},
mounted () {
if (this.data?.length) {
this.ephemeral.periodOpts = uniq(this.data.map(entry => entry.period))
} else {
this.close()
}
}
})
</script>
<style lang="scss" scoped>
@import "@assets/style/_variables.scss";
.c-sub-title {
position: relative;
width: 100%;
text-align: left;
margin-bottom: 1.5rem;
}
.c-btns-container {
position: relative;
margin-top: 2rem;
width: 100%;
justify-content: space-between;
}
.c-invisible-download-helper {
position: absolute;
opacity: 0;
pointer-events: none;
}
</style>
3 changes: 2 additions & 1 deletion frontend/views/containers/payments/PaymentDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export default ({
// NOTE: Only for the historical payments, there is 'period'
const { id, period } = this.$route.query
const payment = this.lightningPayment || // TODO: to be re-worked once lightning network is implemented.
this.currentGroupState.payments[id] || await this.getHistoricalPaymentByHashAndPeriod(id, period)
this.currentGroupState.payments[id] ||
(await this.getHistoricalPaymentDetailsByPeriod(period))[id]
if (id) {
sbp('okTurtles.events/emit', SET_MODAL_QUERIES, 'PaymentDetail', { id })
Expand Down
33 changes: 16 additions & 17 deletions frontend/views/containers/payments/PaymentsMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ const PaymentsMixin: Object = {
return await this.historicalPeriodStampGivenDate(payment.date)
},

async getAllPaymentPeriods () {
return { ...await this.getHistoricalPaymentPeriods(), ...this.groupPeriodPayments }
async getAllPeriodPayments () {
return { ...await this.getHistoricalPeriodPayments(), ...this.groupPeriodPayments }
},

// Oldest key first.
async getAllSortedPeriodKeys () {
const historicalPaymentPeriods = await this.getHistoricalPaymentPeriods()
const historicalPeriodPayments = await this.getHistoricalPeriodPayments()
return [
...Object.keys(historicalPaymentPeriods).sort(),
...Object.keys(historicalPeriodPayments).sort(),
...this.groupSortedPeriodKeys
]
},
Expand Down Expand Up @@ -68,7 +68,7 @@ const PaymentsMixin: Object = {
},

// ====================
async getHistoricalPaymentPeriods () {
async getHistoricalPeriodPayments () {
const ourArchiveKey = `paymentsByPeriod/${this.ourUsername}/${this.currentGroupId}`
return await sbp('gi.db/archive/load', ourArchiveKey) ?? {}
},
Expand All @@ -77,12 +77,12 @@ const PaymentsMixin: Object = {
const sent = []
const received = []
const todo = cloneDeep(this.ourPayments?.todo ?? [])
const paymentPeriods = await this.getAllPaymentPeriods()
const periodPayments = await this.getAllPeriodPayments()
const sortPayments = (f, l) => f.meta.createdDate > l.meta.createdDate ? 1 : -1

for (const periodStamp of Object.keys(paymentPeriods).sort().reverse()) {
for (const periodStamp of Object.keys(periodPayments).sort().reverse()) {
const paymentsByHash = await this.getPaymentDetailsByPeriod(periodStamp)
const { paymentsFrom } = paymentPeriods[periodStamp]
const { paymentsFrom } = periodPayments[periodStamp]
for (const fromUser of Object.keys(paymentsFrom)) {
for (const toUser of Object.keys(paymentsFrom[fromUser])) {
if (toUser === this.ourUsername || fromUser === this.ourUsername) {
Expand Down Expand Up @@ -110,13 +110,12 @@ const PaymentsMixin: Object = {
const paymentHashes = this.paymentHashesForPeriod(period) || []
detailedPayments = Object.fromEntries(paymentHashes.map(hash => [hash, this.currentGroupState.payments[hash]]))
} else {
const paymentsByPeriod = await this.getHistoricalPaymentPeriods()
const paymentsByPeriod = await this.getHistoricalPeriodPayments()
const paymentHashes = paymentHashesFromPaymentPeriod(paymentsByPeriod[period])
const historicalPaymentDetails = await this.getHistoricalPaymentDetailsByPeriod(period)

const paymentsKey = `payments/${this.ourUsername}/${period}/${this.currentGroupId}`
const payments = await sbp('gi.db/archive/load', paymentsKey) || {}
for (const hash of paymentHashes) {
detailedPayments[hash] = payments[hash]
detailedPayments[hash] = historicalPaymentDetails[hash]
}
}
return detailedPayments
Expand All @@ -133,18 +132,18 @@ const PaymentsMixin: Object = {
}
return payments
},
async getHistoricalPaymentByHashAndPeriod (hash: string, period: string) {
async getHistoricalPaymentDetailsByPeriod (period: string) {
const paymentsKey = `payments/${this.ourUsername}/${period}/${this.currentGroupId}`
const payments = await sbp('gi.db/archive/load', paymentsKey) || {}
const paymentDetails = await sbp('gi.db/archive/load', paymentsKey) || {}

return payments[hash]
return paymentDetails
},
async getHaveNeedsSnapshotByPeriod (period: string) {
if (Object.keys(this.groupPeriodPayments).includes(period)) {
return this.groupPeriodPayments[period].haveNeedsSnapshot || []
}

const paymentsByPeriod = await this.getHistoricalPaymentPeriods()
const paymentsByPeriod = await this.getHistoricalPeriodPayments()
return Object.keys(paymentsByPeriod).includes(period)
? paymentsByPeriod[period].haveNeedsSnapshot || []
: []
Expand All @@ -171,7 +170,7 @@ const PaymentsMixin: Object = {
// or an empty object if not found.
// TODOs: rename to getPaymentPeriod, and maybe avoid loading all historical payment periods.
async getPaymentPeriod (period: string) {
return this.groupPeriodPayments[period] ?? (await this.getHistoricalPaymentPeriods())[period] ?? {}
return this.groupPeriodPayments[period] ?? (await this.getHistoricalPeriodPayments())[period] ?? {}
},
// Returns a human-readable description of the time interval identified by a given period stamp.
getPeriodFromStartToDueDate (period) {
Expand Down
Loading

0 comments on commit f60b035

Please sign in to comment.