From 8d08100166513065167d5c7ec39761f1851cb9f6 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 27 Jan 2021 13:59:41 +0100 Subject: [PATCH] WIP: schedules management inerface --- .../components/payment-schedules-list.tsx | 110 +----------- .../components/payment-schedules-table.tsx | 168 ++++++++++++++++++ .../src/javascript/models/payment-schedule.ts | 7 +- app/frontend/src/stylesheets/application.scss | 1 + .../modules/payment-schedules-list.scss | 102 ----------- .../modules/payment-schedules-table.scss | 151 ++++++++++++++++ app/services/payment_schedule_service.rb | 3 +- ...ment_schedule_check_deadline.json.jbuilder | 3 +- ...dmin_payment_schedule_failed.json.jbuilder | 3 +- ...mber_payment_schedule_failed.json.jbuilder | 3 +- config/locales/app.admin.fr.yml | 12 ++ 11 files changed, 349 insertions(+), 214 deletions(-) create mode 100644 app/frontend/src/javascript/components/payment-schedules-table.tsx create mode 100644 app/frontend/src/stylesheets/modules/payment-schedules-table.scss diff --git a/app/frontend/src/javascript/components/payment-schedules-list.tsx b/app/frontend/src/javascript/components/payment-schedules-list.tsx index 9efe13b77..b433d13dd 100644 --- a/app/frontend/src/javascript/components/payment-schedules-list.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-list.tsx @@ -2,19 +2,16 @@ * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices */ -import React, { ReactEventHandler, useState } from 'react'; +import React, { useState } from 'react'; import { IApplication } from '../models/application'; import { useTranslation } from 'react-i18next'; import { Loader } from './loader'; import { react2angular } from 'react2angular'; import PaymentScheduleAPI from '../api/payment-schedule'; import { DocumentFilters } from './document-filters'; -import moment from 'moment'; -import { IFablab } from '../models/fablab'; -import _ from 'lodash'; +import { PaymentSchedulesTable } from './payment-schedules-table'; declare var Application: IApplication; -declare var Fablab: IFablab; const paymentSchedulesList = PaymentScheduleAPI.list({ query: { page: 1, size: 20 } }); @@ -22,7 +19,6 @@ const PaymentSchedulesList: React.FC = () => { const { t } = useTranslation('admin'); const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read()); - const [showExpanded, setShowExpanded] = useState({}); const handleFiltersChange = ({ reference, customer, date }): void => { const api = new PaymentScheduleAPI(); @@ -31,49 +27,6 @@ const PaymentSchedulesList: React.FC = () => { }); }; - const isExpanded = (paymentScheduleId: number): boolean => { - return showExpanded[paymentScheduleId]; - } - - /** - * Return the formatted localized date for the given date - */ - const formatDate = (date: Date): string => { - return Intl.DateTimeFormat().format(moment(date).toDate()); - } - /** - * Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €") - */ - const formatPrice = (price: number): string => { - return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price); - } - - const statusDisplay = (paymentScheduleId: number): string => { - if (isExpanded(paymentScheduleId)) { - return 'table-row' - } else { - return 'none'; - } - } - - const expandCollapseIcon = (paymentScheduleId: number): JSX.Element => { - if (isExpanded(paymentScheduleId)) { - return ; - } else { - return - } - } - - const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => { - return (): void => { - if (isExpanded(paymentScheduleId)) { - setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: false })); - } else { - setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: true })); - } - } - } - return (

@@ -83,64 +36,7 @@ const PaymentSchedulesList: React.FC = () => {
- - - - - - - - - - - {paymentSchedules.map(p => - - )} - -
- Échéancier n°DatePrixClient -
- - - - - - - - - - - - - - -
{expandCollapseIcon(p.id)}{p.reference}{formatDate(p.created_at)}{formatPrice(p.total)}{p.user.name}
- -
- - - - - - - - - - {_.orderBy(p.items, 'due_date').map(item => - - - - - )} - -
ÉchéanceMontantÉtat -
{formatDate(item.due_date)}{formatPrice(item.amount)}{item.state} {item.state === 'paid' ? `(${item.payment_method})` : ''}{item.state === 'paid' ? : ''}
-
-
-
-
    - -
+

); } diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedules-table.tsx new file mode 100644 index 000000000..7e2e02a74 --- /dev/null +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -0,0 +1,168 @@ +/** + * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices + */ + +import React, { ReactEventHandler, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader } from './loader'; +import moment from 'moment'; +import { IFablab } from '../models/fablab'; +import _ from 'lodash'; +import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../models/payment-schedule'; + +declare var Fablab: IFablab; + +interface PaymentSchedulesTableProps { + paymentSchedules: Array, + showCustomer?: boolean +} + +const PaymentSchedulesTableComponent: React.FC = ({ paymentSchedules, showCustomer }) => { + const { t } = useTranslation('admin'); + + const [showExpanded, setShowExpanded] = useState({}); + + const isExpanded = (paymentScheduleId: number): boolean => { + return showExpanded[paymentScheduleId]; + } + + /** + * Return the formatted localized date for the given date + */ + const formatDate = (date: Date): string => { + return Intl.DateTimeFormat().format(moment(date).toDate()); + } + /** + * Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €") + */ + const formatPrice = (price: number): string => { + return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price); + } + + const statusDisplay = (paymentScheduleId: number): string => { + if (isExpanded(paymentScheduleId)) { + return 'table-row' + } else { + return 'none'; + } + } + + const expandCollapseIcon = (paymentScheduleId: number): JSX.Element => { + if (isExpanded(paymentScheduleId)) { + return ; + } else { + return + } + } + + const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => { + return (): void => { + if (isExpanded(paymentScheduleId)) { + setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: false })); + } else { + setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: true })); + } + } + } + + enum TargetType { + Invoice = 'invoices', + PaymentSchedule = 'payment_schedules' + } + const downloadButton = (target: TargetType, id: number): JSX.Element => { + const link = `api/${target}/${id}/download`; + return ( + + + {t('app.admin.invoices.schedules_table.download')} + + ); + } + + const formatState = (item: PaymentScheduleItem): JSX.Element => { + let res = t(`app.admin.invoices.schedules_table.state_${item.state}`); + if (item.state === PaymentScheduleItemState.Paid) { + res += ` (${item.payment_method})`; + } + return {res}; + } + + const itemButtons = (item: PaymentScheduleItem): JSX.Element => { + switch (item.state) { + case PaymentScheduleItemState.Paid: + return downloadButton(TargetType.Invoice, item.invoice_id); + case PaymentScheduleItemState.Pending: + return (); + default: + return + } + } + + return ( + + + + + + + {showCustomer && } + + + + {paymentSchedules.map(p => + + )} + +
+ {t('app.admin.invoices.schedules_table.schedule_num')}{t('app.admin.invoices.schedules_table.date')}{t('app.admin.invoices.schedules_table.price')}{t('app.admin.invoices.schedules_table.customer')} +
+ + + + + + + + {showCustomer && } + + + + + + +
{expandCollapseIcon(p.id)}{p.reference}{formatDate(p.created_at)}{formatPrice(p.total)}{p.user.name}{downloadButton(TargetType.PaymentSchedule, p.id)}
+ +
+ + + + + + + + + + {_.orderBy(p.items, 'due_date').map(item => + + + + + )} + +
{t('app.admin.invoices.schedules_table.deadline')}{t('app.admin.invoices.schedules_table.amount')}{t('app.admin.invoices.schedules_table.state')} +
{formatDate(item.due_date)}{formatPrice(item.amount)}{formatState(item)}{itemButtons(item)}
+
+
+
+ ); +}; +PaymentSchedulesTableComponent.defaultProps = { showCustomer: false }; + + +export const PaymentSchedulesTable: React.FC = ({ paymentSchedules, showCustomer }) => { + return ( + + + + ); +} diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index 51615029b..0945331db 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -1,8 +1,13 @@ +export enum PaymentScheduleItemState { + New = 'new', + Pending = 'pending', + Paid = 'paid' +} export interface PaymentScheduleItem { id: number, amount: number, due_date: Date, - state: string, + state: PaymentScheduleItemState, invoice_id: number, payment_method: string, details: { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 60cd92ea8..da9a56466 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -27,6 +27,7 @@ @import "modules/stripe-modal"; @import "modules/labelled-input"; @import "modules/document-filters"; +@import "modules/payment-schedules-table"; @import "modules/payment-schedules-list"; @import "app.responsive"; diff --git a/app/frontend/src/stylesheets/modules/payment-schedules-list.scss b/app/frontend/src/stylesheets/modules/payment-schedules-list.scss index 42f6c80f4..085a545b4 100644 --- a/app/frontend/src/stylesheets/modules/payment-schedules-list.scss +++ b/app/frontend/src/stylesheets/modules/payment-schedules-list.scss @@ -1,105 +1,3 @@ .schedules-filters { margin-bottom: 2em; } - -.schedules-table { - table-layout: fixed; - border: 1px solid #e9e9e9; - border-top: 0; - margin-bottom: 0; - width: 100%; - max-width: 100%; - background-color: transparent; - border-collapse: collapse; - border-spacing: 0; - - & > thead { - border-top: 1px solid #e9e9e9; - - & > tr > th { - font-weight: 600; - vertical-align: middle; - text-align: center; - padding: 2rem 1rem; - line-height: 1.5; - border: 1px solid #f0f0f0; - border-top: 0; - } - } - - .w-35 { width: 35px; } - .w-120 { width: 120px; } - .w-200 { width: 200px; } - - .schedules-table-body { - table-layout: fixed; - background-color: #fff; - border: 1px solid #e9e9e9; - border-top: 0; - margin-bottom: 0; - width: 100%; - max-width: 100%; - border-collapse: collapse; - border-spacing: 0; - - & > tbody { - background: #f7f7f9; - border-collapse: collapse; - border-spacing: 0; - line-height: 1.5; - - & > tr > td { - padding: 12px 10px; - border: 1px solid #f0f0f0; - border-top: 0; - vertical-align: middle; - font-size: 1.4rem; - line-height: 1.5; - - &.row-header { - text-align: center; - cursor: pointer; - } - } - - .schedule-items-table { - table-layout: fixed; - background-color: #fff; - width: 100%; - max-width: 100%; - margin-bottom: 1rem; - border-collapse: collapse; - border-spacing: 0; - border: 1px solid #e9e9e9; - border-top: 0; - - & > thead { - border-top: 1px solid #e9e9e9; - font-size: 1.4rem; - line-height: 1.5; - - & > tr > th { - border: 1px solid #f0f0f0; - border-top: 0; - border-bottom: 1px solid #e9e9e9; - font-weight: 600; - vertical-align: middle; - text-align: center; - font-size: 1.1rem; - padding: 2rem 1rem; - text-transform: uppercase; - } - } - - & > tbody > tr > td { - vertical-align: middle; - border: 1px solid #f0f0f0; - border-top: 0; - padding: 12px 10px; - font-size: 1.4rem; - line-height: 1.5; - } - } - } - } -} diff --git a/app/frontend/src/stylesheets/modules/payment-schedules-table.scss b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss new file mode 100644 index 000000000..b376d0261 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss @@ -0,0 +1,151 @@ +.schedules-table { + table-layout: fixed; + border: 1px solid #e9e9e9; + border-top: 0; + margin-bottom: 0; + width: 100%; + max-width: 100%; + background-color: transparent; + border-collapse: collapse; + border-spacing: 0; + + & > thead { + border-top: 1px solid #e9e9e9; + + & > tr > th { + font-weight: 600; + vertical-align: middle; + text-align: center; + padding: 2rem 1rem; + line-height: 1.5; + border: 1px solid #f0f0f0; + border-top: 0; + } + } + + .w-35 { width: 35px; } + .w-120 { width: 120px; } + .w-200 { width: 200px; } + + .schedules-table-body { + table-layout: fixed; + background-color: #fff; + border: 1px solid #e9e9e9; + border-top: 0; + margin-bottom: 0; + width: 100%; + max-width: 100%; + border-collapse: collapse; + border-spacing: 0; + + & > tbody { + background: #f7f7f9; + border-collapse: collapse; + border-spacing: 0; + line-height: 1.5; + + & > tr > td { + padding: 12px 10px; + border: 1px solid #f0f0f0; + border-top: 0; + vertical-align: middle; + font-size: 1.4rem; + line-height: 1.5; + + &.row-header { + text-align: center; + cursor: pointer; + } + } + + .schedule-items-table { + table-layout: fixed; + background-color: #fff; + width: 100%; + max-width: 100%; + margin-bottom: 1rem; + border-collapse: collapse; + border-spacing: 0; + border: 1px solid #e9e9e9; + border-top: 0; + + & > thead { + border-top: 1px solid #e9e9e9; + font-size: 1.4rem; + line-height: 1.5; + + & > tr > th { + border: 1px solid #f0f0f0; + border-top: 0; + border-bottom: 1px solid #e9e9e9; + font-weight: 600; + vertical-align: middle; + text-align: center; + font-size: 1.1rem; + padding: 2rem 1rem; + text-transform: uppercase; + } + } + + & > tbody > tr > td { + vertical-align: middle; + border: 1px solid #f0f0f0; + border-top: 0; + padding: 12px 10px; + font-size: 1.4rem; + line-height: 1.5; + } + } + } + } + + .download-button { + color: black; + background-color: #fbfbfb; + display: inline-block; + margin-bottom: 0; + font-weight: normal; + text-align: center; + white-space: nowrap; + vertical-align: middle; + touch-action: manipulation; + cursor: pointer; + background-image: none; + border: 1px solid #c9c9c9; + padding: 6px 12px; + font-size: 16px; + line-height: 1.5; + border-radius: 4px; + user-select: none; + text-decoration: none; + + &:hover { + background-color: #f2f2f2; + color: black; + border-color: #aaaaaa; + text-decoration: none; + } + + &:active { + color: black; + background-color: #f2f2f2; + border-color: #aaaaaa; + outline: 0; + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + } + + & > i { + margin-right: 0.5em; + } + } + + .state-new { + color: #3a3a3a; + } + .state-pending { + color: #d43333; + } + .state-paid { + color: black; + } +} diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 439c9d465..f20d4ae97 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -110,6 +110,7 @@ class PaymentScheduleService def self.list(page, size, filters = {}) ps = PaymentSchedule.includes(:invoicing_profile, :payment_schedule_items, :subscription) .joins(:invoicing_profile) + .order('payment_schedules.created_at DESC') .page(page) .per(size) @@ -129,7 +130,7 @@ class PaymentScheduleService end unless filters[:date].nil? ps = ps.where( - "date_trunc('day', payment_schedules.created_at) = :search", + "date_trunc('day', payment_schedules.created_at) = :search OR date_trunc('day', payment_schedule_items.due_date) = :search", search: "%#{DateTime.iso8601(filters[:date]).to_time.to_date}%" ) end diff --git a/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder b/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder index 08d9326d9..9f9575d88 100644 --- a/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder +++ b/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder @@ -1,4 +1,5 @@ # frozen_string_literal: true json.title notification.notification_type -json.description t('.schedule_deadline') +json.description t('.schedule_deadline', DATE: I18n.l(notification.attached_object.due_date.to_date), + REFERENCE: notification.attached_object.payment_schedule.reference) diff --git a/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder b/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder index 480155950..874330088 100644 --- a/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder +++ b/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder @@ -1,4 +1,5 @@ # frozen_string_literal: true json.title notification.notification_type -json.description t('.schedule_failed') +json.description t('.schedule_failed', DATE: I18n.l(notification.attached_object.due_date.to_date), + REFERENCE: notification.attached_object.payment_schedule.reference) diff --git a/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder b/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder index 480155950..874330088 100644 --- a/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder +++ b/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder @@ -1,4 +1,5 @@ # frozen_string_literal: true json.title notification.notification_type -json.description t('.schedule_failed') +json.description t('.schedule_failed', DATE: I18n.l(notification.attached_object.due_date.to_date), + REFERENCE: notification.attached_object.payment_schedule.reference) diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 3ab8cabd6..32fe6d072 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -642,6 +642,18 @@ fr: stripe_currency: "Devise Stripe" payment_schedules: filter_schedules: "Filtrer les échéanciers" + schedules_table: + schedule_num: "Échéancier n°" + date: "Date" + price: "Prix" + customer: "Client" + deadline: "Échéance" + amount: "Montant" + state: "État" + download: "Télécharger" + state_new: "Pas encore à l'échéance" + state_pending: "Action requise" + state_paid: "Payée" document_filters: reference: "Référence" customer: "Client"