1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

Merge branch 'monthly-payment' into staging

This commit is contained in:
Sylvain 2021-02-04 17:52:14 +01:00
commit 36123990df
20 changed files with 300 additions and 101 deletions

View File

@ -4,6 +4,7 @@
class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user!
before_action :set_payment_schedule, only: %i[download]
before_action :set_payment_schedule_item, only: %i[cash_check]
def list
authorize PaymentSchedule
@ -25,9 +26,22 @@ class API::PaymentSchedulesController < API::ApiController
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
end
def cash_check
schedule = @payment_schedule_item.payment_schedule
authorize schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item)
@payment_schedule_item.update_attributes(state: 'paid', payment_method: 'check')
render :show, status: :ok, location: schedule
end
private
def set_payment_schedule
@payment_schedule = PaymentSchedule.find(params[:id])
end
def set_payment_schedule_item
@payment_schedule_item = PaymentScheduleItem.find(params[:id])
end
end

View File

@ -1,6 +1,6 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { PaymentSchedule, PaymentScheduleIndexRequest } from '../models/payment-schedule';
import { PaymentSchedule, PaymentScheduleIndexRequest, PaymentScheduleItem } from '../models/payment-schedule';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
export default class PaymentScheduleAPI {
@ -9,6 +9,11 @@ export default class PaymentScheduleAPI {
return res?.data;
}
async cashCheck(paymentScheduleItemId: number) {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/cash_check`);
return res?.data;
}
static list (query: PaymentScheduleIndexRequest): IWrapPromise<Array<PaymentSchedule>> {
const api = new PaymentScheduleAPI();
return wrapPromise(api.list(query));

View File

@ -0,0 +1,38 @@
/**
* This component is a template for a clickable button that wraps the application style
*/
import React, { ReactNode, SyntheticEvent } from 'react';
interface FabButtonProps {
onClick?: (event: SyntheticEvent) => void,
icon?: ReactNode,
className?: string,
}
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, children }) => {
/**
* Check if the current component was provided an icon to display
*/
const hasIcon = (): boolean => {
return !!icon;
}
/**
* Handle the action of the button
*/
const handleClick = (e: SyntheticEvent): void => {
if (typeof onClick === 'function') {
onClick(e);
}
}
return (
<button onClick={handleClick} className={`fab-button ${className ? className : ''}`}>
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
{children}
</button>
);
}

View File

@ -2,12 +2,13 @@
* This component is a template for a modal dialog that wraps the application style
*/
import React, { ReactNode } from 'react';
import React, { ReactNode, SyntheticEvent } from 'react';
import Modal from 'react-modal';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import CustomAssetAPI from '../api/custom-asset';
import { CustomAssetName } from '../models/custom-asset';
import { FabButton } from './fab-button';
Modal.setAppElement('body');
@ -25,12 +26,13 @@ interface FabModalProps {
closeButton?: boolean,
className?: string,
width?: ModalSize,
customFooter?: ReactNode
customFooter?: ReactNode,
onConfirm?: (event: SyntheticEvent) => void
}
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm }) => {
const { t } = useTranslation('shared');
const blackLogo = blackLogoFile.read();
@ -56,7 +58,7 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
}
return (
<Modal isOpen={isOpen}
<Modal isOpen={isOpen}onConfirm
className={`fab-modal fab-modal-${width} ${className}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
@ -73,8 +75,8 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
</div>
<div className="fab-modal-footer">
<Loader>
{hasCloseButton() &&<button className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</button>}
{hasConfirmButton() && <span className="modal-btn--confirm">{confirmButton}</span>}
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
{hasConfirmButton() && <FabButton className="modal-btn--confirm" onClick={onConfirm}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
</Loader>
</div>

View File

@ -10,23 +10,73 @@ import { react2angular } from 'react2angular';
import PaymentScheduleAPI from '../api/payment-schedule';
import { DocumentFilters } from './document-filters';
import { PaymentSchedulesTable } from './payment-schedules-table';
import { FabButton } from './fab-button';
declare var Application: IApplication;
const PAGE_SIZE = 20;
const paymentSchedulesList = PaymentScheduleAPI.list({ query: { page: 1, size: 20 } });
const PaymentSchedulesList: React.FC = () => {
const { t } = useTranslation('admin');
const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read());
const [pageNumber, setPageNumber] = useState(1);
const [referenceFilter, setReferenceFilter] = useState(null);
const [customerFilter, setCustomerFilter] = useState(null);
const [dateFilter, setDateFilter] = useState(null);
/**
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.
*/
const handleFiltersChange = ({ reference, customer, date }): void => {
setReferenceFilter(reference);
setCustomerFilter(customer);
setDateFilter(date);
const api = new PaymentScheduleAPI();
api.list({ query: { reference, customer, date, page: 1, size: 20 }}).then((res) => {
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
setPaymentSchedules(res);
});
};
/**
* Fetch from the API the next payment schedules to display, for the current filters, and append them to the current results table.
*/
const handleLoadMore = (): void => {
setPageNumber(pageNumber + 1);
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (): void => {
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
});
}
/**
* Check if the current collection of payment schedules is empty or not.
*/
const hasSchedules = (): boolean => {
return paymentSchedules.length > 0;
}
/**
* Check if there are some results for the current filters that aren't currently shown.
*/
const hasMoreSchedules = (): boolean => {
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
}
return (
<div className="payment-schedules-list">
<h3>
@ -36,7 +86,11 @@ const PaymentSchedulesList: React.FC = () => {
<div className="schedules-filters">
<DocumentFilters onFilterChange={handleFiltersChange} />
</div>
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} />
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
{hasSchedules() && <div className="schedules-list">
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
);
}

View File

@ -2,31 +2,37 @@
* 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, { ReactEventHandler, ReactNode, 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';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
import PaymentScheduleAPI from '../api/payment-schedule';
declare var Fablab: IFablab;
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
showCustomer?: boolean
showCustomer?: boolean,
refreshList: () => void
}
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer }) => {
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList }) => {
const { t } = useTranslation('admin');
const [showExpanded, setShowExpanded] = useState({});
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
*/
const isExpanded = (paymentScheduleId: number): boolean => {
return showExpanded[paymentScheduleId];
return showExpanded.get(paymentScheduleId);
}
/**
@ -70,9 +76,9 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => {
return (): void => {
if (isExpanded(paymentScheduleId)) {
setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: false }));
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, false));
} else {
setShowExpanded(Object.assign({}, showExpanded, { [paymentScheduleId]: true }));
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, true));
}
}
}
@ -119,24 +125,24 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
return downloadButton(TargetType.Invoice, item.invoice_id);
case PaymentScheduleItemState.Pending:
return (
<button className="action-button" onClick={handleConfirmCheckPayment(item)}>
<i className="fas fa-money-check" />
<FabButton onClick={handleConfirmCheckPayment(item)}
icon={<i className="fas fa-money-check" />}>
{t('app.admin.invoices.schedules_table.confirm_payment')}
</button>
</FabButton>
);
case PaymentScheduleItemState.RequireAction:
return (
<button className="action-button" onClick={handleSolveAction(item)}>
<i className="fas fa-wrench" />
<FabButton onClick={handleSolveAction(item)}
icon={<i className="fas fa-wrench" />}>
{t('app.admin.invoices.schedules_table.solve')}
</button>
</FabButton>
);
case PaymentScheduleItemState.RequirePaymentMethod:
return (
<button className="action-button" onClick={handleUpdateCard(item)}>
<i className="fas fa-credit-card" />
<FabButton onClick={handleUpdateCard(item)}
icon={<i className="fas fa-credit-card" />}>
{t('app.admin.invoices.schedules_table.update_card')}
</button>
</FabButton>
);
default:
return <span />
@ -145,14 +151,42 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
/*
TODO
- display confirmation modal
- create /api/payment_schedule/item/confirm_check endpoint and post to it
*/
setTempDeadline(item);
toggleConfirmCashingModal();
}
}
const onCheckCashingConfirmed = (): void => {
const api = new PaymentScheduleAPI();
api.cashCheck(tempDeadline.id).then(() => {
refreshList();
toggleConfirmCashingModal();
});
}
/**
* Show/hide the modal dialog that enable to confirm the cashing of the check for a given deadline.
*/
const toggleConfirmCashingModal = (): void => {
setShowConfirmCashing(!showConfirmCashing);
}
/**
* Dynamically build the content of the modal depending on the currently selected deadline
*/
const cashingModalContent = (): ReactNode => {
if (tempDeadline) {
return (
<span>{t('app.admin.invoices.schedules_table.confirm_check_cashing_body', {
AMOUNT: formatPrice(tempDeadline.amount),
DATE: formatDate(tempDeadline.due_date)
})}</span>
);
}
return <span />;
}
const handleSolveAction = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
/*
@ -238,16 +272,26 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
</tr>)}
</tbody>
</table>
<div className="modals">
<FabModal title={t('app.admin.invoices.schedules_table.confirm_check_cashing')}
isOpen={showConfirmCashing}
toggleModal={toggleConfirmCashingModal}
onConfirm={onCheckCashingConfirmed}
closeButton={true}
confirmButton={t('app.admin.invoices.schedules_table.confirm_button')}>
{cashingModalContent()}
</FabModal>
</div>
</div>
);
};
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer }) => {
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList }) => {
return (
<Loader>
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} />
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} />
</Loader>
);
}

View File

@ -27,6 +27,7 @@ export interface PaymentScheduleItem {
}
export interface PaymentSchedule {
max_length: number;
id: number,
scheduled_type: string,
scheduled_id: number,

View File

@ -22,6 +22,7 @@
@import "modules/stripe";
@import "modules/tour";
@import "modules/fab-modal";
@import "modules/fab-button";
@import "modules/payment-schedule-summary";
@import "modules/wallet-info";
@import "modules/stripe-modal";

View File

@ -0,0 +1,39 @@
.fab-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);
}
&--icon {
margin-right: 0.5em;
}
}

View File

@ -69,23 +69,7 @@
border-top: 1px solid #e5e5e5;
.modal-btn {
margin-bottom: 0;
margin-left: 5px;
display: inline-block;
font-weight: normal;
text-align: center;
white-space: nowrap;
vertical-align: middle;
touch-action: manipulation;
cursor: pointer;
background-image: none;
padding: 6px 12px;
font-size: 16px;
line-height: 1.5;
border-radius: 4px;
&--close {
@extend .modal-btn;
color: black;
background-color: #fbfbfb;
border: 1px solid #c9c9c9;
@ -96,7 +80,7 @@
}
&--confirm {
@extend .modal-btn;
margin-left: 0.5em;
}
}
}

View File

@ -1,3 +1,11 @@
.schedules-filters {
margin-bottom: 2em;
}
.schedules-list {
text-align: center;
.load-more {
margin-top: 2em;
}
}

View File

@ -99,41 +99,8 @@
}
}
.download-button,
.action-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);
}
.download-button {
@extend .fab-button;
& > i {
margin-right: 0.5em;

View File

@ -6,6 +6,10 @@ class PaymentSchedulePolicy < ApplicationPolicy
user.admin? || user.manager?
end
def cash_check?
user.admin? || user.manager?
end
def download?
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
end

View File

@ -309,3 +309,21 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept {
}
}
}
.fab-modal {
.fab-modal-footer {
.modal-btn--confirm {
& {
background-color: $secondary;
color: $secondary-text-color;
border-color: $secondary
}
&:hover {
background-color: $secondary-dark !important;
border-color: $secondary-dark !important;
color: $secondary-text-color;
}
}
}
}

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
json.extract! payment_schedule, :id, :reference, :created_at, :payment_method
json.total payment_schedule.total / 100.00
json.chained_footprint payment_schedule.check_footprint
json.user do
json.name payment_schedule.invoicing_profile.full_name
end
if payment_schedule.operator_profile
json.operator do
json.id payment_schedule.operator_profile.user_id
json.extract! payment_schedule.operator_profile, :first_name, :last_name
end
end
json.items payment_schedule.payment_schedule_items do |item|
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
json.amount item.amount / 100.00
json.client_secret item.payment_intent.client_secret if item.stp_invoice_id && item.state == 'requires_action'
end

View File

@ -1,21 +1,8 @@
# frozen_string_literal: true
max_schedules = @payment_schedules.except(:offset, :limit, :order).count
json.array! @payment_schedules do |ps|
json.extract! ps, :id, :reference, :created_at, :payment_method
json.total ps.total / 100.00
json.chained_footprint ps.check_footprint
json.user do
json.name ps.invoicing_profile.full_name
end
if ps.operator_profile
json.operator do
json.id ps.operator_profile.user_id
json.extract! ps.operator_profile, :first_name, :last_name
end
end
json.items ps.payment_schedule_items do |item|
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
json.amount item.amount / 100.00
json.client_secret item.payment_intent.client_secret if item.stp_invoice_id && item.state == 'requires_action'
end
json.max_length max_schedules
json.partial! 'api/payment_schedules/payment_schedule', payment_schedule: ps
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/payment_schedules/payment_schedule', payment_schedule: @payment_schedule_item.payment_schedule

View File

@ -642,6 +642,8 @@ en:
stripe_currency: "Stripe currency"
payment_schedules:
filter_schedules: "Filter schedules"
no_payment_schedules: "No payment schedules to display"
load_more: "Load more"
schedules_table:
schedule_num: "Schedule #"
date: "Date"
@ -662,6 +664,9 @@ en:
confirm_payment: "Confirm payment"
solve: "Solve"
update_card: "Update the card"
confirm_check_cashing: "Confirm the cashing of the check"
confirm_check_cashing_body: "You must cash a check of {AMOUNT} for the deadline of {DATE}. By confirming the cashing of the check, an invoice will be generated for this due date."
confirm_button: "Confirm"
document_filters:
reference: "Reference"
customer: "Customer"

View File

@ -642,6 +642,8 @@ fr:
stripe_currency: "Devise Stripe"
payment_schedules:
filter_schedules: "Filtrer les échéanciers"
no_payment_schedules: "Pas d'échéancier à afficher"
load_more: "Voir plus"
schedules_table:
schedule_num: "Échéancier n°"
date: "Date"
@ -662,6 +664,9 @@ fr:
confirm_payment: "Confirmer l'encaissement"
solve: "Résoudre"
update_card: "Mettre à jour la carte"
confirm_check_cashing: "Confirmer l'encaissement du chèque"
confirm_check_cashing_body: "Vous devez encaisser un chèque de {AMOUNT} pour l'échéance du {DATE}. En confirmant l'encaissement du chèque, une facture sera générée pour cette échéance."
confirm_button: "Confirmer"
document_filters:
reference: "Référence"
customer: "Client"

View File

@ -114,6 +114,7 @@ Rails.application.routes.draw do
resources :payment_schedules, only: %i[show] do
post 'list', action: 'list', on: :collection
get 'download', on: :member
post 'items/:id/cash_check', action: 'cash_check', on: :collection
end
resources :i_calendar, only: %i[index create destroy] do