diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 2e7c6c202..2d3618bf9 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -273,6 +273,24 @@ class Meta: class CreditTradeUpdateSerializer(serializers.ModelSerializer): def validate(self, data): + if (data.get('fair_market_value_per_credit') == 0 and + data.get('zero_reason') is None): + allowed_types = list( + CreditTradeType.objects + .filter(the_type__in=[ + "Credit Validation", "Credit Retirement", "Part 3 Award" + ]) + .only('id') + ) + + credit_trade_type = data.get('type') + + if credit_trade_type not in allowed_types: + raise serializers.ValidationError({ + 'zeroDollarReason': 'Zero Dollar Reason is required ' + 'for Credit Transfers with 0 Dollar per Credit' + }) + return data class Meta: diff --git a/backend/api/services/CreditTradeService.py b/backend/api/services/CreditTradeService.py index 661bc2e6d..c19b6fff2 100644 --- a/backend/api/services/CreditTradeService.py +++ b/backend/api/services/CreditTradeService.py @@ -7,6 +7,7 @@ from api.exceptions import PositiveIntegerException from django.core.exceptions import ValidationError from django.db.models import Q +from django.db import transaction import datetime @@ -137,23 +138,26 @@ def approve(credit_trade): return credit_trade @staticmethod + @transaction.non_atomic_requests() def transfer_credits(_from, _to, credit_trade_id, num_of_credits, effective_date): - from_starting_bal = OrganizationBalance.objects.get( + from_starting_bal, created = OrganizationBalance.objects.get_or_create( organization_id=_from.id, - expiration_date=None) + expiration_date=None, + defaults={'validated_credits': 0}) - to_starting_bal = OrganizationBalance.objects.get( + to_starting_bal, created = OrganizationBalance.objects.get_or_create( organization_id=_to.id, - expiration_date=None) + expiration_date=None, + defaults={'validated_credits': 0}) # Compute for end balance from_credits = from_starting_bal.validated_credits - num_of_credits to_credits = to_starting_bal.validated_credits + num_of_credits - if 0 > from_credits: + if from_credits < 0: raise PositiveIntegerException("Can't complete transaction," - "insufficient credits") + "`{}` has insufficient credits".format(_from.name)) # Update old balance effective date from_starting_bal.expiration_date = effective_date @@ -180,3 +184,76 @@ def transfer_credits(_from, _to, credit_trade_id, num_of_credits, from_new_bal.save() to_new_bal.save() + + @staticmethod + def validate_credits(credit_trades): + errors = [] + temp_storage = [] + + for credit_trade in credit_trades: + from_starting_index, from_starting_balance = CreditTradeService. \ + get_temp_balance(temp_storage, credit_trade.credits_from.id) + + to_starting_index, to_starting_balance = CreditTradeService. \ + get_temp_balance(temp_storage, credit_trade.credits_to.id) + + from_credits_remaining = from_starting_balance - \ + credit_trade.number_of_credits + + to_credits_remaining = to_starting_balance + \ + credit_trade.number_of_credits + + CreditTradeService.update_temp_balance( + temp_storage, + from_starting_index, + from_credits_remaining, + credit_trade.credits_from.id) + + CreditTradeService.update_temp_balance( + temp_storage, + to_starting_index, + to_credits_remaining, + credit_trade.credits_to.id) + + if from_credits_remaining < 0: + errors.append( + "[ID: {}] " + "Can't complete transaction," + "`{}` has insufficient credits.". + format(credit_trade.id, credit_trade.credits_from.name)) + + if len(errors) > 0: + raise PositiveIntegerException(errors) + + @staticmethod + def get_temp_balance(storage, id): + starting_balance = None + index = None + + if len(storage) > 0: + for balance_index, balance in enumerate(storage): + if balance["id"] == id: + starting_balance = balance["credits"] + index = balance_index + + if starting_balance is None: + try: # if balance hasn't been populated, get from the database + organization_balance = OrganizationBalance.objects.get( + organization_id=id, + expiration_date=None) + + starting_balance = organization_balance.validated_credits + except OrganizationBalance.DoesNotExist: + starting_balance = 0 + + return index, starting_balance + + @staticmethod + def update_temp_balance(storage, index, credits, id): + if index is None: + storage.append({ + "id": id, + "credits": credits + }) + else: + storage[index]["credits"] = credits diff --git a/backend/api/test_credit_trades.py b/backend/api/test_credit_trades.py index 584c16fef..af4f7ffbd 100644 --- a/backend/api/test_credit_trades.py +++ b/backend/api/test_credit_trades.py @@ -4,11 +4,18 @@ from django.test import TestCase, Client from rest_framework import status +from api.exceptions import PositiveIntegerException + +from api.models.CreditTrade import CreditTrade from api.models.CreditTradeStatus import CreditTradeStatus from api.models.CreditTradeType import CreditTradeType from api.models.CreditTradeZeroReason import CreditTradeZeroReason +from api.models.Organization import Organization +from api.models.OrganizationBalance import OrganizationBalance from api.models.User import User +from api.services.CreditTradeService import CreditTradeService + # Credit Trade Statuses STATUS_DRAFT = 1 STATUS_SUBMITTED = 2 @@ -137,7 +144,7 @@ def test_government_user_add_credit_transfer(self): # credit transfer with 0 fair market value: # If the type is 'Sell', Fair Market Value needs to be greater than 0 # or zero dollar reason must be provided - # This tests if we try to submit a 0 dollar credit transaction with no + # This tests if we try to submit a 0 dollar credit transaction with no # reason def test_government_user_add_credit_transfer(self): credit_trade_status, created = CreditTradeStatus.objects.get_or_create( @@ -199,3 +206,236 @@ def test_government_user_add_credit_transfer(self): # 400 since zero reason was set to None assert response.status_code == status.HTTP_201_CREATED + + # As a government user, I should be able to validate approved credit + # transfers: + # It should raise an exception if it sees any fuel suppliers with + # insufficient funds + def test_validate_credit(self): + credit_trade_status, created = CreditTradeStatus.objects.get_or_create( + status='Approved') + + credit_trade_type, created = CreditTradeType.objects.get_or_create( + the_type='Sell') + + credit_trade_zero_reason, created = CreditTradeZeroReason.objects \ + .get_or_create(reason='Other', display_order=2) + + CreditTrade.objects.create(status=credit_trade_status, + initiator=self.user_2.organization, + respondent=self.user_3.organization, + type=credit_trade_type, + number_of_credits=1000000000, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + credit_trades = CreditTrade.objects.filter( + status_id=credit_trade_status.id) + + with self.assertRaises(PositiveIntegerException): + CreditTradeService.validate_credits(credit_trades) + + # As a government user, I should be able to validate approved credit + # transfers: + # It should raise an exception if it sees any fuel suppliers with + # insufficient funds + # This is a slightly more complex test where we have multi credit trades + # with new organizations that bounces the number of credits up and down + def test_validate_credit_complex(self): + credit_trade_status, created = CreditTradeStatus.objects.get_or_create( + status='Approved') + + credit_trade_type, created = CreditTradeType.objects.get_or_create( + the_type='Sell') + + credit_trade_zero_reason, created = CreditTradeZeroReason.objects \ + .get_or_create(reason='Other', display_order=2) + + from_organization = Organization.objects.create( + name="Test 1", + actions_type_id=1, + status_id=1) + to_organization = Organization.objects.create( + name="Test 2", + actions_type_id=1, + status_id=1) + + # Award Test 1 with 1000 credits (new organizations start + # with 0 credits) + # (Please note in most cases we should use a different type + # but to reduce the number of things to keep track, lets just + # transfer from organization: 1 (BC Government)) + CreditTrade.objects.create(status=credit_trade_status, + initiator=self.gov_user.organization, + respondent=from_organization, + type=credit_trade_type, + number_of_credits=1000, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + # Transfer 500 from Test 1 to Test 2 + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=500, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + # Transfer 700 from Test 1 to Test 2 + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=700, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + credit_trades = CreditTrade.objects.filter( + status_id=credit_trade_status.id) + + # this should now raise an exception since we tried transferring + # 1200 credits when only 1000 are available + with self.assertRaises(PositiveIntegerException): + CreditTradeService.validate_credits(credit_trades) + + # As a government user, I should be able to validate approved credit + # transfers: + # It should raise an exception if it sees any fuel suppliers with + # insufficient funds + # This test is similar to the one above, but should succeed as we're going + # to allocate the right amount of credits this time + def test_validate_credit_success(self): + credit_trade_status, created = CreditTradeStatus.objects.get_or_create( + status='Approved') + + credit_trade_type, created = CreditTradeType.objects.get_or_create( + the_type='Sell') + + credit_trade_zero_reason, created = CreditTradeZeroReason.objects \ + .get_or_create(reason='Other', display_order=2) + + from_organization = Organization.objects.create( + name="Test 1", + actions_type_id=1, + status_id=1) + to_organization = Organization.objects.create( + name="Test 2", + actions_type_id=1, + status_id=1) + + # Award Test 1 with 1000 credits (new organizations start + # with 0 credits) + # (Please note in most cases we should use a different type + # but to reduce the number of things to keep track, lets just + # transfer from organization: 1 (BC Government)) + CreditTrade.objects.create(status=credit_trade_status, + initiator=self.gov_user.organization, + respondent=from_organization, + type=credit_trade_type, + number_of_credits=1000, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + # Transfer 500 from Test 1 to Test 2 + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=500, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + # Transfer 300 from Test 1 to Test 2 + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=500, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + credit_trades = CreditTrade.objects.filter( + status_id=credit_trade_status.id) + + # no exceptions should be raised + CreditTradeService.validate_credits(credit_trades) + + # As a government user, I should be able to process all the approved + # credit transfers + # This test is similar to the one above, but a functional test to check + # if the commit actually works + def test_batch_process(self): + credit_trade_status, created = CreditTradeStatus.objects.get_or_create( + status='Approved') + + credit_trade_type, created = CreditTradeType.objects.get_or_create( + the_type='Sell') + + credit_trade_zero_reason, created = CreditTradeZeroReason.objects \ + .get_or_create(reason='Other', display_order=2) + + from_organization = Organization.objects.create( + name="Test 1", + actions_type_id=1, + status_id=1) + to_organization = Organization.objects.create( + name="Test 2", + actions_type_id=1, + status_id=1) + + CreditTrade.objects.create(status=credit_trade_status, + initiator=self.gov_user.organization, + respondent=from_organization, + type=credit_trade_type, + number_of_credits=1000, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=500, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + CreditTrade.objects.create(status=credit_trade_status, + initiator=from_organization, + respondent=to_organization, + type=credit_trade_type, + number_of_credits=400, + fair_market_value_per_credit=0, + zero_reason=credit_trade_zero_reason, + trade_effective_date=datetime.datetime + .today().strftime('%Y-%m-%d')) + + credit_trades = CreditTrade.objects.filter( + status_id=credit_trade_status.id) + + response = self.gov_client.put('/api/credit_trades/batch_process') + assert response.status_code == status.HTTP_200_OK + + organization_balance = OrganizationBalance.objects.get( + organization_id=from_organization.id, + expiration_date=None) + + assert organization_balance.validated_credits == 100 diff --git a/backend/api/viewsets/CreditTrade.py b/backend/api/viewsets/CreditTrade.py index 5ff453a64..c1c4cc5f4 100644 --- a/backend/api/viewsets/CreditTrade.py +++ b/backend/api/viewsets/CreditTrade.py @@ -18,6 +18,7 @@ from api.services.CreditTradeService import CreditTradeService + class CreditTradeViewSet(AuditableMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): @@ -53,7 +54,8 @@ def get_queryset(self): for the currently authenticated user. """ user = self.request.user - return CreditTradeService.get_organization_credit_trades(user.organization) + return CreditTradeService.get_organization_credit_trades( + user.organization) def perform_create(self, serializer): credit_trade = serializer.save() @@ -102,7 +104,8 @@ def list_approved(self, request): status_approved = CreditTradeStatus.objects \ .get(status="Approved") - credit_trades = CreditTrade.objects.filter(status_id=status_approved.id) + credit_trades = CreditTrade.objects.filter( + status_id=status_approved.id).order_by('id') serializer = self.get_serializer(credit_trades, many=True) return Response(serializer.data) @@ -112,9 +115,13 @@ def batch_process(self, request): status_approved = CreditTradeStatus.objects \ .get(status="Approved") - credit_trades = CreditTrade.objects.filter(status_id=status_approved.id) + credit_trades = CreditTrade.objects.filter( + status_id=status_approved.id).order_by('id') + + CreditTradeService.validate_credits(credit_trades) for credit_trade in credit_trades: + credit_trade.update_user_id = request.user.id CreditTradeService.approve(credit_trade) - return Response(None, status=status.HTTP_200_OK) \ No newline at end of file + return Response(None, status=status.HTTP_200_OK) diff --git a/backend/api/viewsets/Organization.py b/backend/api/viewsets/Organization.py index 81ac027fc..c29e174df 100644 --- a/backend/api/viewsets/Organization.py +++ b/backend/api/viewsets/Organization.py @@ -84,8 +84,11 @@ def users(self, request, pk=None): @list_route(methods=['get']) def fuel_suppliers(self, request): - fuel_suppliers = Organization.objects.filter( - type=OrganizationType.objects.get(type="Part3FuelSupplier")) + fuel_suppliers = Organization.objects.extra( + select={'lower_name': 'lower(name)'}) \ + .filter( + type=OrganizationType.objects.get(type="Part3FuelSupplier")) \ + .order_by('lower_name') serializer = self.get_serializer(fuel_suppliers, many=True) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/frontend/src/actions/creditTransfersActions.js b/frontend/src/actions/creditTransfersActions.js index 2ed120fa5..0d03519b7 100644 --- a/frontend/src/actions/creditTransfersActions.js +++ b/frontend/src/actions/creditTransfersActions.js @@ -2,6 +2,7 @@ import axios from 'axios'; import * as ActionTypes from '../constants/actionTypes'; import * as Routes from '../constants/routes'; +import { CREDIT_TRANSFER_STATUS, CREDIT_TRANSFER_TYPES, DEFAULT_ORGANIZATION } from '../constants/values'; /* * Credit Transfers @@ -16,6 +17,57 @@ export const getCreditTransfers = () => (dispatch) => { }); }; +export const prepareCreditTransfer = (fields) => { + // API data structure + const data = { + initiator: (fields.creditsFrom.id > 0) + ? fields.creditsFrom.id + : DEFAULT_ORGANIZATION.id, + note: fields.note, + numberOfCredits: parseInt(fields.numberOfCredits, 10), + respondent: (fields.creditsTo.id > 0) + ? fields.creditsTo.id + : DEFAULT_ORGANIZATION.id, + status: CREDIT_TRANSFER_STATUS.approved.id, + tradeEffectiveDate: fields.tradeEffectiveDate, + type: fields.transferType, + zeroReason: fields.zeroDollarReason + }; + + switch (fields.transferType) { + case CREDIT_TRANSFER_TYPES.part3Award.id.toString(): + case CREDIT_TRANSFER_TYPES.validation.id.toString(): + data.initiator = DEFAULT_ORGANIZATION.id; + data.respondent = fields.creditsTo.id; + + break; + case CREDIT_TRANSFER_TYPES.retirement.id.toString(): + data.initiator = DEFAULT_ORGANIZATION.id; + data.respondent = fields.creditsFrom.id; + + break; + default: + data.initiator = (fields.creditsFrom.id > 0) + ? fields.creditsFrom.id + : DEFAULT_ORGANIZATION.id; + + data.respondent = (fields.creditsTo.id > 0) + ? fields.creditsTo.id + : DEFAULT_ORGANIZATION.id; + } + + if (fields.transferType === CREDIT_TRANSFER_TYPES.sell.id.toString()) { + data.fairMarketValuePerCredit = fields.fairMarketValuePerCredit; + } + + if (fields.transferType !== CREDIT_TRANSFER_TYPES.sell.id.toString() || + parseFloat(fields.fairMarketValuePerCredit) > 0) { + data.zeroReason = ''; + } + + return data; +}; + export const shouldGetCreditTransfers = (state) => { const { creditTransfers } = state; if (!creditTransfers) { @@ -188,6 +240,7 @@ export const updateCreditTransfer = (id, data) => (dispatch) => { dispatch(updateCreditTransferSuccess(response.data)); }).catch((error) => { dispatch(updateCreditTransferError(error.response.data)); + return Promise.reject(error); }); }; @@ -251,6 +304,7 @@ export const processApprovedCreditTransfers = () => (dispatch) => { dispatch(processApprovedCreditTransfersSuccess(response.data)); }).catch((error) => { dispatch(processApprovedCreditTransfersError(error.response.data)); + return Promise.reject(error); }); }; @@ -267,6 +321,6 @@ const processApprovedCreditTransfersSuccess = data => ({ const processApprovedCreditTransfersError = error => ({ name: 'ERROR_APPROVED_CREDIT_TRANSFERS', - type: ActionTypes.ERROR, + type: ActionTypes.COMMIT_ERRORS, errorMessage: error }); diff --git a/frontend/src/admin/historical_data_entry/HistoricalDataEntryContainer.js b/frontend/src/admin/historical_data_entry/HistoricalDataEntryContainer.js index 0b36410aa..99e5f3786 100644 --- a/frontend/src/admin/historical_data_entry/HistoricalDataEntryContainer.js +++ b/frontend/src/admin/historical_data_entry/HistoricalDataEntryContainer.js @@ -8,13 +8,13 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; -import { CREDIT_TRANSFER_STATUS, CREDIT_TRANSFER_TYPES, DEFAULT_ORGANIZATION } from '../../constants/values'; import { addCreditTransfer, deleteCreditTransfer, getApprovedCreditTransfersIfNeeded, invalidateCreditTransfer, invalidateCreditTransfers, + prepareCreditTransfer, processApprovedCreditTransfers } from '../../actions/creditTransfersActions'; import { getFuelSuppliers } from '../../actions/organizationActions'; @@ -90,25 +90,7 @@ class HistoricalDataEntryContainer extends Component { _handleSubmit (event, status) { event.preventDefault(); - // API data structure - const data = { - initiator: (this.state.fields.creditsFrom.id > 0) - ? this.state.fields.creditsFrom.id - : DEFAULT_ORGANIZATION.id, - note: this.state.fields.note, - numberOfCredits: parseInt(this.state.fields.numberOfCredits, 10), - respondent: (this.state.fields.creditsTo.id > 0) - ? this.state.fields.creditsTo.id - : DEFAULT_ORGANIZATION.id, - status: CREDIT_TRANSFER_STATUS.approved.id, - tradeEffectiveDate: this.state.fields.tradeEffectiveDate, - type: this.state.fields.transferType, - zeroReason: this.state.fields.zeroDollarReason - }; - - if (this.state.fields.transferType === CREDIT_TRANSFER_TYPES.sell.id.toString()) { - data.fairMarketValuePerCredit = this.state.fields.fairMarketValuePerCredit; - } + const data = this.props.prepareCreditTransfer(this.state.fields); this.props.addCreditTransfer(data).then(() => { this.props.invalidateCreditTransfers(); @@ -157,8 +139,9 @@ class HistoricalDataEntryContainer extends Component { render () { return ( ({ - errors: state.rootReducer.creditTransfer.errors, + addErrors: state.rootReducer.creditTransfer.errors, + commitErrors: state.rootReducer.creditTransfers.errors, fuelSuppliers: state.rootReducer.fuelSuppliersRequest.fuelSuppliers, historicalData: { items: state.rootReducer.creditTransfers.items, @@ -213,6 +200,7 @@ const mapDispatchToProps = dispatch => ({ }, invalidateCreditTransfer: bindActionCreators(invalidateCreditTransfer, dispatch), invalidateCreditTransfers: bindActionCreators(invalidateCreditTransfers, dispatch), + prepareCreditTransfer: fields => prepareCreditTransfer(fields), processApprovedCreditTransfers: bindActionCreators(processApprovedCreditTransfers, dispatch) }); diff --git a/frontend/src/admin/historical_data_entry/HistoricalDataEntryEditContainer.js b/frontend/src/admin/historical_data_entry/HistoricalDataEntryEditContainer.js index 55e43a56b..978f6080c 100644 --- a/frontend/src/admin/historical_data_entry/HistoricalDataEntryEditContainer.js +++ b/frontend/src/admin/historical_data_entry/HistoricalDataEntryEditContainer.js @@ -10,11 +10,11 @@ import PropTypes from 'prop-types'; import * as Lang from '../../constants/langEnUs'; import * as Routes from '../../constants/routes'; -import { CREDIT_TRANSFER_STATUS } from '../../constants/values'; import { getFuelSuppliers } from '../../actions/organizationActions'; import { getCreditTransfer, invalidateCreditTransfers, + prepareCreditTransfer, updateCreditTransfer } from '../../actions/creditTransfersActions'; import history from '../../app/History'; @@ -33,7 +33,8 @@ class HistoricalDataEntryEditContainer extends Component { tradeEffectiveDate: '', note: '', numberOfCredits: '', - transferType: '' + transferType: '', + zeroDollarReason: '' }, totalValue: 0 }; @@ -68,19 +69,7 @@ class HistoricalDataEntryEditContainer extends Component { _handleSubmit (event, status) { event.preventDefault(); - // API data structure - const data = { - fairMarketValuePerCredit: (this.state.fields.transferType !== '5') ? this.state.fields.fairMarketValuePerCredit : null, - initiator: this.state.fields.creditsFrom.id, - note: this.state.fields.note, - numberOfCredits: parseInt(this.state.fields.numberOfCredits, 10), - respondent: this.state.fields.creditsTo.id, - status: CREDIT_TRANSFER_STATUS.approved.id, - tradeEffectiveDate: this.state.fields.tradeEffectiveDate, - type: this.state.fields.transferType, - zeroReason: this.state.fields.zeroDollarReason - }; - + const data = this.props.prepareCreditTransfer(this.state.fields); const { id } = this.props.item; this.props.updateCreditTransfer(id, data).then((response) => { @@ -171,11 +160,12 @@ HistoricalDataEntryEditContainer.propTypes = { id: PropTypes.string.isRequired }).isRequired }).isRequired, + prepareCreditTransfer: PropTypes.func.isRequired, updateCreditTransfer: PropTypes.func.isRequired }; const mapStateToProps = state => ({ - errors: state.rootReducer.creditTransfers.errors, + errors: state.rootReducer.creditTransfer.errors, fuelSuppliers: state.rootReducer.fuelSuppliersRequest.fuelSuppliers, isFetching: state.rootReducer.creditTransfer.isFetching, item: state.rootReducer.creditTransfer.item @@ -185,6 +175,7 @@ const mapDispatchToProps = dispatch => ({ getCreditTransfer: bindActionCreators(getCreditTransfer, dispatch), getFuelSuppliers: bindActionCreators(getFuelSuppliers, dispatch), invalidateCreditTransfers: bindActionCreators(invalidateCreditTransfers, dispatch), + prepareCreditTransfer: fields => prepareCreditTransfer(fields), updateCreditTransfer: bindActionCreators(updateCreditTransfer, dispatch) }); diff --git a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryForm.js b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryForm.js index a8d0b8ccb..5bef8d3f5 100644 --- a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryForm.js +++ b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryForm.js @@ -4,10 +4,14 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Errors from '../../../app/components/Errors'; import HistoricalDataEntryFormDetails from './HistoricalDataEntryFormDetails'; const HistoricalDataEntryForm = props => (
+ {Object.keys(props.errors).length > 0 && + + }
props.handleSubmit(event, '')} @@ -27,6 +31,7 @@ const HistoricalDataEntryForm = props => ( HistoricalDataEntryForm.propTypes = { actions: PropTypes.arrayOf(PropTypes.string).isRequired, + errors: PropTypes.shape({}).isRequired, fuelSuppliers: PropTypes.arrayOf(PropTypes.shape()).isRequired, fields: PropTypes.shape({ creditsFrom: PropTypes.shape({ diff --git a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryFormDetails.js b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryFormDetails.js index 3cda8f576..b0069ce25 100644 --- a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryFormDetails.js +++ b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryFormDetails.js @@ -7,7 +7,7 @@ import numeral from 'numeral'; import * as NumberFormat from '../../../constants/numeralFormats'; -import { CREDIT_TRANSFER_TYPES, DEFAULT_ORGANIZATION, ZERO_DOLLAR_REASON } from '../../../constants/values'; +import { CREDIT_TRANSFER_TYPES, ZERO_DOLLAR_REASON } from '../../../constants/values'; import HistoricalDataEntryFormNote from './HistoricalDataEntryFormNote'; import HistoricalDataEntryFormButtons from './HistoricalDataEntryFormButtons'; @@ -43,52 +43,15 @@ const HistoricalDataEntryFormDetails = props => (
- {props.fields.creditsFrom.id !== DEFAULT_ORGANIZATION.id && - ![CREDIT_TRANSFER_TYPES.part3Award.id.toString(), - CREDIT_TRANSFER_TYPES.validation.id.toString()] - .includes(props.fields.transferType) && - - } - {props.fields.creditsFrom.id === DEFAULT_ORGANIZATION.id && - - } - {[CREDIT_TRANSFER_TYPES.part3Award.id.toString(), + {![CREDIT_TRANSFER_TYPES.part3Award.id.toString(), CREDIT_TRANSFER_TYPES.validation.id.toString()] .includes(props.fields.transferType) && - } -
- -
- {props.fields.creditsTo.id !== 1 && - props.fields.transferType !== CREDIT_TRANSFER_TYPES.retirement.id.toString() && - } - {props.fields.creditsTo.id === DEFAULT_ORGANIZATION.id && + {[CREDIT_TRANSFER_TYPES.part3Award.id.toString(), + CREDIT_TRANSFER_TYPES.validation.id.toString()] + .includes(props.fields.transferType) && + + } +
+ +
+ {props.fields.transferType !== CREDIT_TRANSFER_TYPES.retirement.id.toString() && } {props.fields.transferType === CREDIT_TRANSFER_TYPES.retirement.id.toString() && @@ -176,8 +164,8 @@ const HistoricalDataEntryFormDetails = props => (
diff --git a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryPage.js b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryPage.js index 66a19c906..bfe2192ed 100644 --- a/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryPage.js +++ b/frontend/src/admin/historical_data_entry/components/HistoricalDataEntryPage.js @@ -22,12 +22,9 @@ const HistoricalDataEntryPage = (props) => {

{props.title}

- {Object.keys(props.errors).length > 0 && - - } - { {isFetching && } {!isFetching && + Object.keys(props.commitErrors).length > 0 && + + } + {!isFetching && { }; HistoricalDataEntryPage.propTypes = { + addErrors: PropTypes.shape({}).isRequired, + commitErrors: PropTypes.shape({}).isRequired, deleteCreditTransfer: PropTypes.func.isRequired, - errors: PropTypes.shape({}).isRequired, fields: PropTypes.shape({ creditsFrom: PropTypes.shape({ name: PropTypes.string, diff --git a/frontend/src/admin/historical_data_entry/components/HistoricalDataTable.js b/frontend/src/admin/historical_data_entry/components/HistoricalDataTable.js index 272a09ab1..d332d3067 100644 --- a/frontend/src/admin/historical_data_entry/components/HistoricalDataTable.js +++ b/frontend/src/admin/historical_data_entry/components/HistoricalDataTable.js @@ -31,24 +31,24 @@ const HistoricalDataTable = (props) => { accessor: item => item.type.id, className: 'col-transfer-type', Cell: (row) => { - let value = ''; + let content = ''; switch (row.value) { case CREDIT_TRANSFER_TYPES.validation.id: - value = 'Validation'; + content = 'Validation'; break; case CREDIT_TRANSFER_TYPES.retirement.id: - value = 'Reduction'; + content = 'Reduction'; break; case CREDIT_TRANSFER_TYPES.part3Award.id: - value = 'Part 3 Award'; + content = 'Part 3 Award'; break; default: - value = 'Credit Transfer'; + content = 'Credit Transfer'; } return ( -
{value}
+
{content}
); } }, { @@ -56,17 +56,38 @@ const HistoricalDataTable = (props) => { Header: 'Credits From', accessor: item => item.creditsFrom.name, minWidth: 200, - Cell: row => ( -
{row.value}
- ) + Cell: (row) => { + let content; + + if (row.original.type.id !== CREDIT_TRANSFER_TYPES.part3Award.id && + row.original.type.id !== CREDIT_TRANSFER_TYPES.validation.id) { + content = row.value; + } else { + content = 'N/A'; + } + + return ( +
{content}
+ ); + } }, { id: 'creditsTo', Header: 'Credits To', accessor: item => item.creditsTo.name, minWidth: 200, - Cell: row => ( -
{row.value}
- ) + Cell: (row) => { + let content; + + if (row.original.type.id !== CREDIT_TRANSFER_TYPES.retirement.id) { + content = row.value; + } else { + content = 'N/A'; + } + + return ( +
{content}
+ ); + } }, { id: 'numberOfCredits', Header: 'Credits', @@ -76,7 +97,21 @@ const HistoricalDataTable = (props) => { id: 'totalvalue', Header: 'Price', accessor: item => numeral(item.totalValue).format(NumberFormat.CURRENCY), - className: 'col-price' + className: 'col-price', + Cell: (row) => { + let content; + + if (row.original.type.id === CREDIT_TRANSFER_TYPES.buy.id || + row.original.type.id === CREDIT_TRANSFER_TYPES.sell.id) { + content = row.value; + } else { + content = 'N/A'; + } + + return ( +
{content}
+ ); + } }, { id: 'zeroReason', Header: 'Zero Reason', @@ -84,16 +119,16 @@ const HistoricalDataTable = (props) => { className: 'col-zero-reason', Cell: (row) => { const zeroReason = row.value; - let value; + let content; if (zeroReason && zeroReason.id === ZERO_DOLLAR_REASON.affiliate.id) { - value = ZERO_DOLLAR_REASON.affiliate.description; + content = ZERO_DOLLAR_REASON.affiliate.description; } else if (zeroReason && zeroReason.id === ZERO_DOLLAR_REASON.other.id) { - value = ZERO_DOLLAR_REASON.other.description; + content = ZERO_DOLLAR_REASON.other.description; } return ( -
{value}
+
{content}
); } }, { @@ -124,7 +159,7 @@ const HistoricalDataTable = (props) => { return ( { const columns = [{ Header: 'ID', accessor: 'id', - maxWidth: 35 + className: 'col-id', + resizable: false, + width: 35 }, { id: 'creditsFrom', - Header: 'From', + Header: 'Credits From', accessor: item => item.creditsFrom.name, minWidth: 230, Cell: row => ( @@ -28,23 +31,60 @@ const CreditTransferTable = (props) => { ) }, { id: 'creditsTo', - Header: 'To', + Header: 'Credits To', accessor: item => item.creditsTo.name, Cell: row => (
{row.value}
) + }, { + id: 'transactionType', + Header: 'Transaction Type', + accessor: item => item.type.id, + className: 'col-transfer-type', + Cell: (row) => { + let value = ''; + + switch (row.value) { + case CREDIT_TRANSFER_TYPES.validation.id: + value = 'Validation'; + break; + case CREDIT_TRANSFER_TYPES.retirement.id: + value = 'Reduction'; + break; + case CREDIT_TRANSFER_TYPES.part3Award.id: + value = 'Part 3 Award'; + break; + default: + value = 'Credit Transfer'; + } + + return ( +
{value}
+ ); + } }, { id: 'numberOfCredits', - Header: 'Credits', + Header: 'Quantity of Credits', + className: 'col-credits', accessor: item => numeral(item.numberOfCredits).format(NumberFormat.INT) }, { id: 'fairMarketValuePerCredit', Header: 'Value Per Credit', - accessor: item => numeral(item.fairMarketValuePerCredit).format(NumberFormat.DECIMAL) - }, { - id: 'totalvalue', - Header: 'Total Amount', - accessor: item => numeral(item.totalValue).format(NumberFormat.CURRENCY) + className: 'col-price', + accessor: item => numeral(item.fairMarketValuePerCredit).format(NumberFormat.CURRENCY), + Cell: (row) => { + const creditTrade = row.row; + let content = ''; + + if (creditTrade.transactionType === CREDIT_TRANSFER_TYPES.buy.id || + creditTrade.transactionType === CREDIT_TRANSFER_TYPES.sell.id) { + content = row.value; + } + + return ( +
{content}
+ ); + } }, { id: 'status', Header: 'Status', @@ -53,6 +93,7 @@ const CreditTransferTable = (props) => { }, { id: 'updateTimestamp', Header: 'Last Updated On', + className: 'col-date', accessor: item => moment(item.updateTimestamp).format('LL'), minWidth: 150 }, { diff --git a/frontend/src/reducers/creditTransferReducer.js b/frontend/src/reducers/creditTransferReducer.js index bcabf2966..097119d05 100644 --- a/frontend/src/reducers/creditTransferReducer.js +++ b/frontend/src/reducers/creditTransferReducer.js @@ -77,6 +77,13 @@ const creditTransfers = (state = { success: true, items: action.data }); + case ActionTypes.COMMIT_ERRORS: + return Object.assign({}, state, { + didInvalidate: true, + isFetching: false, + success: false, + errors: action.errorMessage + }); case ActionTypes.ERROR: return Object.assign({}, state, { isFetching: false, @@ -85,7 +92,8 @@ const creditTransfers = (state = { }); case ActionTypes.INVALIDATE_CREDIT_TRANSFERS: return Object.assign({}, state, { - didInvalidate: true + didInvalidate: true, + errors: {} }); default: return state; diff --git a/frontend/styles/CreditTransfers.scss b/frontend/styles/CreditTransfers.scss index 20c351a6c..c7919bc89 100644 --- a/frontend/styles/CreditTransfers.scss +++ b/frontend/styles/CreditTransfers.scss @@ -242,4 +242,12 @@ .account-activity-table .react-bs-table { max-height: 500px; overflow: auto; +} + +.page_credit_transactions { + .rt-table { + .col-credits, .col-date, .col-id, .col-price, .col-total { + text-align: right; + } + } } \ No newline at end of file diff --git a/frontend/styles/HistoricalDataEntry.scss b/frontend/styles/HistoricalDataEntry.scss index f30990f16..babdef7e4 100644 --- a/frontend/styles/HistoricalDataEntry.scss +++ b/frontend/styles/HistoricalDataEntry.scss @@ -16,6 +16,13 @@ } } + .btn.disabled, + .btn[disabled], + fieldset[disabled] .btn { + background-color: #fff; + border-color: #ccc; + } + .btn-container { text-align: right; }