Merge pull request #1069 from dod-ccpo/clin-obligated-and-total-funding
CLIN obligated and total funding
This commit is contained in:
commit
1fe755bb69
29
alembic/versions/f03333c42bdb_add_total_amount_to_clins.py
Normal file
29
alembic/versions/f03333c42bdb_add_total_amount_to_clins.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Add total amount to CLINs
|
||||
|
||||
Revision ID: f03333c42bdb
|
||||
Revises: 4a3122ffe898
|
||||
Create Date: 2019-09-04 15:35:39.446486
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f03333c42bdb' # pragma: allowlist secret
|
||||
down_revision = '30ea1cb20807' # pragma: allowlist secret
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('clins', sa.Column('total_amount', sa.Numeric(scale=2), nullable=True))
|
||||
op.execute("UPDATE clins SET total_amount = 0")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('clins', 'total_amount')
|
||||
# ### end Alembic commands ###
|
@ -58,6 +58,7 @@ class TaskOrders(BaseDomainClass):
|
||||
number=clin_data["number"],
|
||||
start_date=clin_data["start_date"],
|
||||
end_date=clin_data["end_date"],
|
||||
total_amount=clin_data["total_amount"],
|
||||
obligated_amount=clin_data["obligated_amount"],
|
||||
jedi_clin_type=clin_data["jedi_clin_type"],
|
||||
)
|
||||
|
@ -7,9 +7,10 @@ from wtforms.fields import (
|
||||
HiddenField,
|
||||
)
|
||||
from wtforms.fields.html5 import DateField
|
||||
from wtforms.validators import Required, Optional, Length
|
||||
from wtforms.validators import Required, Optional, Length, NumberRange, ValidationError
|
||||
from flask_wtf import FlaskForm
|
||||
from datetime import datetime
|
||||
from numbers import Number
|
||||
|
||||
from .data import JEDI_CLIN_TYPES
|
||||
from .fields import SelectField
|
||||
@ -17,6 +18,8 @@ from .forms import BaseForm
|
||||
from atst.utils.localization import translate
|
||||
from flask import current_app as app
|
||||
|
||||
MAX_CLIN_AMOUNT = 1000000000
|
||||
|
||||
|
||||
def coerce_enum(enum_inst):
|
||||
if getattr(enum_inst, "value", None):
|
||||
@ -25,6 +28,17 @@ def coerce_enum(enum_inst):
|
||||
return enum_inst
|
||||
|
||||
|
||||
def validate_funding(form, field):
|
||||
if (
|
||||
isinstance(form.total_amount.data, Number)
|
||||
and isinstance(field.data, Number)
|
||||
and form.total_amount.data < field.data
|
||||
):
|
||||
raise ValidationError(
|
||||
translate("forms.task_order.clin_funding_errors.obligated_amount_error")
|
||||
)
|
||||
|
||||
|
||||
class CLINForm(FlaskForm):
|
||||
jedi_clin_type = SelectField(
|
||||
translate("task_orders.form.clin_type_label"),
|
||||
@ -47,9 +61,26 @@ class CLINForm(FlaskForm):
|
||||
format="%m/%d/%Y",
|
||||
validators=[Optional()],
|
||||
)
|
||||
total_amount = DecimalField(
|
||||
label=translate("task_orders.form.total_funds_label"),
|
||||
validators=[
|
||||
NumberRange(
|
||||
0,
|
||||
MAX_CLIN_AMOUNT,
|
||||
translate("forms.task_order.clin_funding_errors.funding_range_error"),
|
||||
)
|
||||
],
|
||||
)
|
||||
obligated_amount = DecimalField(
|
||||
label=translate("task_orders.form.obligated_funds_label"),
|
||||
validators=[Optional()],
|
||||
validators=[
|
||||
validate_funding,
|
||||
NumberRange(
|
||||
0,
|
||||
MAX_CLIN_AMOUNT,
|
||||
translate("forms.task_order.clin_funding_errors.funding_range_error"),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def validate(self, *args, **kwargs):
|
||||
|
@ -23,6 +23,7 @@ class CLIN(Base, mixins.TimestampsMixin):
|
||||
number = Column(String, nullable=True)
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=True)
|
||||
total_amount = Column(Numeric(scale=2), nullable=True)
|
||||
obligated_amount = Column(Numeric(scale=2), nullable=True)
|
||||
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True)
|
||||
|
||||
@ -46,6 +47,7 @@ class CLIN(Base, mixins.TimestampsMixin):
|
||||
self.number,
|
||||
self.start_date,
|
||||
self.end_date,
|
||||
self.total_amount,
|
||||
self.obligated_amount,
|
||||
self.jedi_clin_type,
|
||||
]
|
||||
|
@ -6,7 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from atst.models import Attachment, Base, mixins, types
|
||||
from atst.models.clin import JEDICLINType
|
||||
from atst.utils.clock import Clock
|
||||
|
||||
|
||||
@ -148,10 +147,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
def total_obligated_funds(self):
|
||||
total = 0
|
||||
for clin in self.clins:
|
||||
if clin.obligated_amount is not None and clin.jedi_clin_type in [
|
||||
JEDICLINType.JEDI_CLIN_1,
|
||||
JEDICLINType.JEDI_CLIN_3,
|
||||
]:
|
||||
if clin.obligated_amount is not None:
|
||||
total += clin.obligated_amount
|
||||
return total
|
||||
|
||||
@ -159,8 +155,8 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
def total_contract_amount(self):
|
||||
total = 0
|
||||
for clin in self.clins:
|
||||
if clin.obligated_amount is not None:
|
||||
total += clin.obligated_amount
|
||||
if clin.total_amount is not None:
|
||||
total += clin.total_amount
|
||||
return total
|
||||
|
||||
@property
|
||||
|
39
js/components/clin_dollar_amount.js
Normal file
39
js/components/clin_dollar_amount.js
Normal file
@ -0,0 +1,39 @@
|
||||
import inputValidations from '../lib/input_validations'
|
||||
import TextInputMixin from '../mixins/text_input_mixin'
|
||||
|
||||
export default {
|
||||
name: 'clindollaramount',
|
||||
|
||||
mixins: [TextInputMixin],
|
||||
|
||||
props: {
|
||||
fundingValid: Boolean,
|
||||
},
|
||||
|
||||
computed: {
|
||||
rawValue: function() {
|
||||
return this._rawValue(this.value)
|
||||
},
|
||||
showFundingError: function() {
|
||||
return this.showError || !this.fundingValid
|
||||
},
|
||||
showFundingValid: function() {
|
||||
return this.showValid && this.fundingValid
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
fundingValid: function(oldVal, newVal) {
|
||||
this._checkIfValid({ value: this.value, invalidate: true })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
_validate: function(value) {
|
||||
const rawValue = this._rawValue(value)
|
||||
if (rawValue < 0 || rawValue > 1000000000 || !this.fundingValid) {
|
||||
return false
|
||||
}
|
||||
return inputValidations[this.validation].match.test(rawValue)
|
||||
},
|
||||
},
|
||||
}
|
@ -6,7 +6,10 @@ import { emitEvent } from '../lib/emitters'
|
||||
import Modal from '../mixins/modal'
|
||||
import optionsinput from './options_input'
|
||||
import textinput from './text_input'
|
||||
import clindollaramount from './clin_dollar_amount'
|
||||
|
||||
const TOTAL_AMOUNT = 'total_amount'
|
||||
const OBLIGATED_AMOUNT = 'obligated_amount'
|
||||
const START_DATE = 'start_date'
|
||||
const END_DATE = 'end_date'
|
||||
const POP = 'period_of_performance'
|
||||
@ -19,12 +22,21 @@ export default {
|
||||
DateSelector,
|
||||
optionsinput,
|
||||
textinput,
|
||||
clindollaramount,
|
||||
},
|
||||
|
||||
mixins: [Modal],
|
||||
|
||||
props: {
|
||||
initialClinIndex: Number,
|
||||
initialTotal: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
initialObligated: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
initialStartDate: {
|
||||
type: String,
|
||||
default: null,
|
||||
@ -54,6 +66,10 @@ export default {
|
||||
const end = !!this.initialEndDate
|
||||
? new Date(this.initialEndDate)
|
||||
: undefined
|
||||
const fundingValidation =
|
||||
this.initialObligated && this.initialTotal
|
||||
? this.initialObligated <= this.initialTotal
|
||||
: true
|
||||
const popValidation = !this.initialStartDate ? false : start < end
|
||||
const clinNumber = !!this.initialClinNumber
|
||||
? this.initialClinNumber
|
||||
@ -63,6 +79,7 @@ export default {
|
||||
|
||||
return {
|
||||
clinIndex: this.initialClinIndex,
|
||||
clinNumber: clinNumber,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
popValid: popValidation,
|
||||
@ -93,14 +110,23 @@ export default {
|
||||
)}.`,
|
||||
},
|
||||
],
|
||||
totalAmount: this.initialTotal || 0,
|
||||
obligatedAmount: this.initialObligated || 0,
|
||||
fundingValid: fundingValidation,
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.$root.$on('field-change', this.handleFieldChange)
|
||||
this.validateFunding()
|
||||
},
|
||||
|
||||
created: function() {
|
||||
emitEvent('clin-change', this, {
|
||||
id: this._uid,
|
||||
obligatedAmount: this.initialObligated,
|
||||
totalAmount: this.initialTotal,
|
||||
})
|
||||
emitEvent('field-mount', this, {
|
||||
optional: false,
|
||||
name: 'clins-' + this.clinIndex + '-' + POP,
|
||||
@ -109,6 +135,14 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
clinChangeEvent: function() {
|
||||
emitEvent('clin-change', this, {
|
||||
id: this._uid,
|
||||
obligatedAmount: this.initialObligated,
|
||||
totalAmount: this.initialTotal,
|
||||
})
|
||||
},
|
||||
|
||||
checkPopValid: function() {
|
||||
return (
|
||||
this.popDateOrder() &&
|
||||
@ -153,9 +187,25 @@ export default {
|
||||
return true
|
||||
},
|
||||
|
||||
checkFundingValid: function() {
|
||||
return this.obligatedAmount <= this.totalAmount
|
||||
},
|
||||
|
||||
validateFunding: function() {
|
||||
if (this.totalAmount && this.obligatedAmount) {
|
||||
this.fundingValid = this.checkFundingValid()
|
||||
}
|
||||
},
|
||||
|
||||
handleFieldChange: function(event) {
|
||||
if (this._uid === event.parent_uid) {
|
||||
if (event.name.includes(START_DATE)) {
|
||||
if (event.name.includes(TOTAL_AMOUNT)) {
|
||||
this.totalAmount = parseFloat(event.value)
|
||||
this.validateFunding()
|
||||
} else if (event.name.includes(OBLIGATED_AMOUNT)) {
|
||||
this.obligatedAmount = parseFloat(event.value)
|
||||
this.validateFunding()
|
||||
} else if (event.name.includes(START_DATE)) {
|
||||
if (!!event.value) this.startDate = new Date(event.value)
|
||||
if (!!event.valid) this.startDateValid = event.valid
|
||||
this.validatePop()
|
||||
@ -186,6 +236,20 @@ export default {
|
||||
return `CLIN`
|
||||
}
|
||||
},
|
||||
percentObligated: function() {
|
||||
const percentage = (this.obligatedAmount / this.totalAmount) * 100
|
||||
if (!!percentage) {
|
||||
if (percentage > 0 && percentage < 1) {
|
||||
return '<1%'
|
||||
} else if (percentage > 99 && percentage < 100) {
|
||||
return '>99%'
|
||||
} else {
|
||||
return `${percentage.toFixed(0)}%`
|
||||
}
|
||||
} else {
|
||||
return '0%'
|
||||
}
|
||||
},
|
||||
|
||||
removeModalId: function() {
|
||||
return `remove-clin-${this.clinIndex}`
|
||||
|
@ -1,174 +1,6 @@
|
||||
import MaskedInput, { conformToMask } from 'vue-text-mask'
|
||||
import inputValidations from '../lib/input_validations'
|
||||
import { formatDollars } from '../lib/dollars'
|
||||
import { emitEvent } from '../lib/emitters'
|
||||
import TextInputMixin from '../mixins/text_input_mixin'
|
||||
|
||||
export default {
|
||||
name: 'textinput',
|
||||
|
||||
components: {
|
||||
MaskedInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
name: String,
|
||||
validation: {
|
||||
type: String,
|
||||
default: () => 'anything',
|
||||
},
|
||||
initialValue: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
initialErrors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
paragraph: String,
|
||||
noMaxWidth: String,
|
||||
optional: Boolean,
|
||||
watch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
showError: (this.initialErrors && this.initialErrors.length) || false,
|
||||
showValid: false,
|
||||
mask: inputValidations[this.validation].mask,
|
||||
pipe: inputValidations[this.validation].pipe || undefined,
|
||||
keepCharPositions:
|
||||
inputValidations[this.validation].keepCharPositions || false,
|
||||
validationError:
|
||||
this.initialErrors.join(' ') ||
|
||||
inputValidations[this.validation].validationError,
|
||||
value: this.initialValue,
|
||||
modified: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
rawValue: function() {
|
||||
return this._rawValue(this.value)
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
if (this.value) {
|
||||
this._checkIfValid({
|
||||
value: this.value,
|
||||
invalidate: true,
|
||||
showValidationIcon: false,
|
||||
})
|
||||
|
||||
if (this.mask && this.validation !== 'email') {
|
||||
const mask =
|
||||
typeof this.mask.mask !== 'function'
|
||||
? this.mask
|
||||
: mask.mask(this.value).filter(val => val !== '[]')
|
||||
|
||||
this.value = conformToMask(this.value, mask).conformedValue
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created: function() {
|
||||
emitEvent('field-mount', this, {
|
||||
optional: this.optional,
|
||||
name: this.name,
|
||||
valid: this._isValid(this.value),
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
// When user types a character
|
||||
onInput: function(e) {
|
||||
// When we use the native textarea element, we receive an event object
|
||||
// When we use the masked-input component, we receive the value directly
|
||||
const value = typeof e === 'object' ? e.target.value : e
|
||||
this.value = value
|
||||
this.modified = true
|
||||
this._checkIfValid({ value })
|
||||
},
|
||||
|
||||
// When field is blurred (un-focused)
|
||||
onChange: function(e) {
|
||||
// Only invalidate the field when it blurs
|
||||
this._checkIfValid({ value: e.target.value, invalidate: true })
|
||||
},
|
||||
|
||||
onBlur: function(e) {
|
||||
if (!(this.optional && e.target.value === '')) {
|
||||
this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
|
||||
} else if (this.modified && !this.optional) {
|
||||
this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
|
||||
}
|
||||
this.value = e.target.value.trim()
|
||||
|
||||
if (this.validation === 'dollars') {
|
||||
let value = Number.isNaN(e.target.value) ? '0' : e.target.value
|
||||
this.value = formatDollars(this._rawValue(value))
|
||||
}
|
||||
},
|
||||
|
||||
//
|
||||
_checkIfValid: function({
|
||||
value,
|
||||
invalidate = false,
|
||||
showValidationIcon = true,
|
||||
}) {
|
||||
const valid = this._isValid(value)
|
||||
if (this.modified) {
|
||||
this.validationError = inputValidations[this.validation].validationError
|
||||
}
|
||||
|
||||
// Show error messages or not
|
||||
if (valid) {
|
||||
this.showError = false
|
||||
} else if (invalidate) {
|
||||
this.showError = true
|
||||
}
|
||||
|
||||
if (showValidationIcon) {
|
||||
this.showValid = this.value != '' && valid
|
||||
}
|
||||
|
||||
// Emit a change event
|
||||
emitEvent('field-change', this, {
|
||||
value: this._rawValue(value),
|
||||
valid: this._isValid(value),
|
||||
name: this.name,
|
||||
watch: this.watch,
|
||||
})
|
||||
},
|
||||
|
||||
_rawValue: function(value) {
|
||||
return inputValidations[this.validation].unmask.reduce(
|
||||
(currentValue, character) => {
|
||||
return currentValue.split(character).join('')
|
||||
},
|
||||
value
|
||||
)
|
||||
},
|
||||
|
||||
_validate: function(value) {
|
||||
return inputValidations[this.validation].match.test(this._rawValue(value))
|
||||
},
|
||||
|
||||
_isValid: function(value) {
|
||||
let valid = this._validate(value)
|
||||
|
||||
if (!this.modified && this.initialErrors && this.initialErrors.length) {
|
||||
valid = false
|
||||
} else if (this.optional && value === '') {
|
||||
valid = true
|
||||
} else if (!this.optional && value === '') {
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
},
|
||||
},
|
||||
mixins: [TextInputMixin],
|
||||
}
|
||||
|
@ -34,6 +34,13 @@ export default {
|
||||
unmask: ['$', ','],
|
||||
validationError: 'Please enter a dollar amount',
|
||||
},
|
||||
clinDollars: {
|
||||
mask: createNumberMask({ prefix: '$', allowDecimal: true }),
|
||||
match: /^-?\d+\.?\d*$/,
|
||||
unmask: ['$', ','],
|
||||
validationError:
|
||||
'Please enter a dollar amount between $0.00 and $1,000,000,000.00',
|
||||
},
|
||||
email: {
|
||||
mask: emailMask,
|
||||
match: /^.+@[^.].*\.[a-zA-Z]{2,10}$/,
|
||||
|
173
js/mixins/text_input_mixin.js
Normal file
173
js/mixins/text_input_mixin.js
Normal file
@ -0,0 +1,173 @@
|
||||
import MaskedInput, { conformToMask } from 'vue-text-mask'
|
||||
import inputValidations from '../lib/input_validations'
|
||||
import { formatDollars } from '../lib/dollars'
|
||||
import { emitEvent } from '../lib/emitters'
|
||||
|
||||
export default {
|
||||
name: 'textinput',
|
||||
|
||||
components: {
|
||||
MaskedInput,
|
||||
},
|
||||
|
||||
props: {
|
||||
name: String,
|
||||
validation: {
|
||||
type: String,
|
||||
default: () => 'anything',
|
||||
},
|
||||
initialValue: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
initialErrors: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
paragraph: String,
|
||||
noMaxWidth: String,
|
||||
optional: Boolean,
|
||||
watch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
showError: (this.initialErrors && this.initialErrors.length) || false,
|
||||
showValid: false,
|
||||
mask: inputValidations[this.validation].mask,
|
||||
pipe: inputValidations[this.validation].pipe || undefined,
|
||||
keepCharPositions:
|
||||
inputValidations[this.validation].keepCharPositions || false,
|
||||
validationError:
|
||||
this.initialErrors.join(' ') ||
|
||||
inputValidations[this.validation].validationError,
|
||||
value: this.initialValue,
|
||||
modified: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
rawValue: function() {
|
||||
return this._rawValue(this.value)
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
if (this.value) {
|
||||
this._checkIfValid({
|
||||
value: this.value,
|
||||
invalidate: true,
|
||||
showValidationIcon: false,
|
||||
})
|
||||
|
||||
if (this.mask && this.validation !== 'email') {
|
||||
const mask =
|
||||
typeof this.mask.mask !== 'function'
|
||||
? this.mask
|
||||
: mask.mask(this.value).filter(val => val !== '[]')
|
||||
|
||||
this.value = conformToMask(this.value, mask).conformedValue
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created: function() {
|
||||
emitEvent('field-mount', this, {
|
||||
optional: this.optional,
|
||||
name: this.name,
|
||||
valid: this._isValid(this.value),
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
// When user types a character
|
||||
onInput: function(e) {
|
||||
// When we use the native textarea element, we receive an event object
|
||||
// When we use the masked-input component, we receive the value directly
|
||||
const value = typeof e === 'object' ? e.target.value : e
|
||||
this.value = value
|
||||
this.modified = true
|
||||
this._checkIfValid({ value })
|
||||
},
|
||||
|
||||
// When field is blurred (un-focused)
|
||||
onChange: function(e) {
|
||||
// Only invalidate the field when it blurs
|
||||
this._checkIfValid({ value: e.target.value, invalidate: true })
|
||||
},
|
||||
|
||||
onBlur: function(e) {
|
||||
if (!(this.optional && e.target.value === '')) {
|
||||
this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
|
||||
} else if (this.modified && !this.optional) {
|
||||
this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
|
||||
}
|
||||
this.value = e.target.value.trim()
|
||||
|
||||
if (this.validation === 'dollars') {
|
||||
let value = Number.isNaN(e.target.value) ? '0' : e.target.value
|
||||
this.value = formatDollars(this._rawValue(value))
|
||||
}
|
||||
},
|
||||
|
||||
//
|
||||
_checkIfValid: function({
|
||||
value,
|
||||
invalidate = false,
|
||||
showValidationIcon = true,
|
||||
}) {
|
||||
const valid = this._isValid(value)
|
||||
if (this.modified) {
|
||||
this.validationError = inputValidations[this.validation].validationError
|
||||
}
|
||||
|
||||
// Show error messages or not
|
||||
if (valid) {
|
||||
this.showError = false
|
||||
} else if (invalidate) {
|
||||
this.showError = true
|
||||
}
|
||||
|
||||
if (showValidationIcon) {
|
||||
this.showValid = this.value != '' && valid
|
||||
}
|
||||
|
||||
// Emit a change event
|
||||
emitEvent('field-change', this, {
|
||||
value: this._rawValue(value),
|
||||
valid: this._isValid(value),
|
||||
name: this.name,
|
||||
watch: this.watch,
|
||||
})
|
||||
},
|
||||
|
||||
_rawValue: function(value) {
|
||||
return inputValidations[this.validation].unmask.reduce(
|
||||
(currentValue, character) => {
|
||||
return currentValue.split(character).join('')
|
||||
},
|
||||
value
|
||||
)
|
||||
},
|
||||
|
||||
_validate: function(value) {
|
||||
return inputValidations[this.validation].match.test(this._rawValue(value))
|
||||
},
|
||||
|
||||
_isValid: function(value) {
|
||||
let valid = this._validate(value)
|
||||
if (!this.modified && this.initialErrors && this.initialErrors.length) {
|
||||
valid = false
|
||||
} else if (this.optional && value === '') {
|
||||
valid = true
|
||||
} else if (!this.optional && value === '') {
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
},
|
||||
},
|
||||
}
|
70
templates/components/clin_dollar_amount.html
Normal file
70
templates/components/clin_dollar_amount.html
Normal file
@ -0,0 +1,70 @@
|
||||
{% from 'components/icon.html' import Icon %}
|
||||
|
||||
{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%}
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
<clindollaramount
|
||||
v-cloak
|
||||
inline-template
|
||||
{% if funding_validation %}
|
||||
:funding-valid='fundingValid'
|
||||
{% else %}
|
||||
:funding-valid='true'
|
||||
{% endif %}
|
||||
{% if field %}
|
||||
name='{{ field.name }}'
|
||||
{% if field.data is not none %}initial-value='{{ field.data }}'{% endif %}
|
||||
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
|
||||
key='{{ field.name }}'
|
||||
{% else %}
|
||||
:name="'clins-' + clinIndex + '-' + '{{ type }}' + '_amount'"
|
||||
:key="'clins-' + clinIndex + '-' + '{{ type }}' + '_amount'"
|
||||
{% endif %}
|
||||
|
||||
validation="clinDollars"
|
||||
:watch='true'>
|
||||
<div v-bind:class="['usa-input usa-input--validation--dollars', { 'usa-input--error': showFundingError, 'usa-input--success': showFundingValid}]">
|
||||
{% if field %}
|
||||
<label for='{{ field.name }}'>
|
||||
{% else %}
|
||||
<label :for='name'>
|
||||
{% endif %}
|
||||
|
||||
{% if type=="obligated" %}
|
||||
<div class="usa-input__title">{{ 'task_orders.form.obligated_funds_label' | translate }}</div>
|
||||
{% else %}
|
||||
<div class="usa-input__title">{{ 'task_orders.form.total_funds_label' | translate }}</div>
|
||||
{% endif %}
|
||||
<span v-show='showFundingError'>{{ Icon('alert',classes="icon-validation") }}</span>
|
||||
<span v-show='showFundingValid'>{{ Icon('ok',classes="icon-validation") }}</span>
|
||||
</label>
|
||||
|
||||
<masked-input
|
||||
v-on:input='onInput'
|
||||
v-on:blur='onBlur'
|
||||
v-on:change='onChange'
|
||||
v-bind:value='value'
|
||||
v-bind:mask='mask'
|
||||
v-bind:pipe='pipe'
|
||||
v-bind:keep-char-positions='keepCharPositions'
|
||||
v-bind:aria-invalid='showError'
|
||||
type='text'
|
||||
:id='name'
|
||||
ref='input'>
|
||||
</masked-input>
|
||||
|
||||
<input type='hidden' v-bind:value='rawValue' :name='name' />
|
||||
<template v-if='!fundingValid'>
|
||||
<span class='usa-input__message'>{{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}</span>
|
||||
</template>
|
||||
<template v-else-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class='usa-input__message'></span>
|
||||
</template>
|
||||
</div>
|
||||
</clindollaramount>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
@ -43,7 +43,7 @@
|
||||
<th>{{ "task_orders.review.clins.type" | translate }}</th>
|
||||
<th>{{ "task_orders.review.clins.idiq_clin_description" | translate }}</th>
|
||||
<th>{{ "task_orders.review.clins.pop" | translate }}</th>
|
||||
<th class="task-order__amount">{{ "task_orders.review.clins.amount" | translate }}</th>
|
||||
<th class="task-order__amount">{{ "task_orders.review.clins.total_amount" | translate }}</th>
|
||||
<th class="task-order__amount">{{ "task_orders.review.clins.obligated" | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -58,7 +58,7 @@
|
||||
{{ clin.start_date | formattedDate }} - {{ clin.end_date | formattedDate }}
|
||||
</td>
|
||||
{# TODO: Swap in total CLIN amount #}
|
||||
<td class="task-order__amount">$123,456,789.00</td>
|
||||
<td class="task-order__amount">{{ clin.total_amount | dollars }}</td>
|
||||
<td class="task-order__amount">{{ clin.obligated_amount | dollars }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -81,6 +81,9 @@
|
||||
<div class="card__header">
|
||||
<h3>Task Order #{{ task_order.number }}</h3>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<b>Total amount: </b>{{ task_order.total_contract_amount | dollars }}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<b>Obligated amount: </b>{{ task_order.total_obligated_funds | dollars }}
|
||||
</div>
|
||||
|
@ -5,6 +5,7 @@
|
||||
{% from 'components/icon.html' import Icon %}
|
||||
{% from 'components/options_input.html' import OptionsInput %}
|
||||
{% from 'components/text_input.html' import TextInput %}
|
||||
{% from "components/clin_dollar_amount.html" import CLINDollarAmount %}
|
||||
{% from 'task_orders/form_header.html' import TOFormStepHeader %}
|
||||
|
||||
{% set action = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id) %}
|
||||
@ -16,6 +17,8 @@
|
||||
<clin-fields
|
||||
{% if fields %}
|
||||
v-bind:initial-clin-index='{{ index }}'
|
||||
v-bind:initial-total='{{ fields.total_amount.data or 0 }}'
|
||||
v-bind:initial-obligated='{{ fields.obligated_amount.data or 0 }}'
|
||||
v-bind:initial-start-date="'{{ fields.start_date.data | string }}'"
|
||||
v-bind:initial-end-date="'{{ fields.end_date.data | string }}'"
|
||||
v-bind:initial-clin-number="'{{ fields.number.data | string }}'"
|
||||
@ -115,56 +118,18 @@
|
||||
{{ 'task_orders.form.clin_funding' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if fields %}
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
{{ TextInput(fields.obligated_amount, validation='dollars', watch=True, optional=False) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ CLINDollarAmount("total", field=fields.total_amount) }}
|
||||
{{ CLINDollarAmount("obligated", field=fields.obligated_amount, funding_validation=True) }}
|
||||
{% else %}
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
<textinput
|
||||
v-cloak
|
||||
inline-template
|
||||
:name="'clins-' + clinIndex + '-obligated_amount'"
|
||||
validation="dollars"
|
||||
:watch='true'>
|
||||
<div v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
|
||||
<label :for="name">
|
||||
<div class="usa-input__title">{{ 'task_orders.form.obligated_funds_label' | translate }}</div>
|
||||
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
|
||||
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation") }}</span>
|
||||
</label>
|
||||
|
||||
<masked-input
|
||||
v-on:input='onInput'
|
||||
v-on:blur='onBlur'
|
||||
v-on:change='onChange'
|
||||
v-bind:value='value'
|
||||
v-bind:mask='mask'
|
||||
v-bind:pipe='pipe'
|
||||
v-bind:keep-char-positions='keepCharPositions'
|
||||
v-bind:aria-invalid='showError'
|
||||
v-bind:show-mask='false'
|
||||
type='text'
|
||||
:id='name'
|
||||
ref='input'>
|
||||
</masked-input>
|
||||
|
||||
<input type='hidden' v-bind:value='rawValue' :name='name' />
|
||||
|
||||
<template v-if='showError'>
|
||||
<span class='usa-input__message' v-html='validationError'></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class='usa-input__message'></span>
|
||||
</template>
|
||||
</div>
|
||||
</textinput>
|
||||
</div>
|
||||
</div>
|
||||
{{ CLINDollarAmount("total") }}
|
||||
{{ CLINDollarAmount("obligated", funding_validation=True) }}
|
||||
{% endif %}
|
||||
|
||||
<div class="h5 clin-card__title">Percent Obligated</div>
|
||||
<p v-html='percentObligated'></p>
|
||||
|
||||
<hr>
|
||||
<div class="form-row">
|
||||
<div class="h4 clin-card__title">
|
||||
|
@ -84,6 +84,7 @@ def test_create_adds_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
{
|
||||
"jedi_clin_type": "JEDI_CLIN_1",
|
||||
@ -91,6 +92,7 @@ def test_create_adds_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
]
|
||||
task_order = TaskOrders.create(
|
||||
@ -113,6 +115,7 @@ def test_update_adds_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
{
|
||||
"jedi_clin_type": "JEDI_CLIN_1",
|
||||
@ -120,6 +123,7 @@ def test_update_adds_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
]
|
||||
task_order = TaskOrders.create(
|
||||
@ -144,6 +148,7 @@ def test_update_does_not_duplicate_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
{
|
||||
"jedi_clin_type": "JEDI_CLIN_1",
|
||||
@ -151,6 +156,7 @@ def test_update_does_not_duplicate_clins():
|
||||
"start_date": date(2020, 1, 1),
|
||||
"end_date": date(2021, 1, 1),
|
||||
"obligated_amount": Decimal("5000"),
|
||||
"total_amount": Decimal("10000"),
|
||||
},
|
||||
]
|
||||
task_order = TaskOrders.update(
|
||||
|
@ -291,7 +291,8 @@ class CLINFactory(Base):
|
||||
number = factory.LazyFunction(random_task_order_number)
|
||||
start_date = datetime.date.today()
|
||||
end_date = factory.LazyFunction(random_future_date)
|
||||
obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 999999))
|
||||
total_amount = factory.LazyFunction(lambda *args: random.randint(50000, 999999))
|
||||
obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 50000))
|
||||
jedi_clin_type = factory.LazyFunction(
|
||||
lambda *args: random.choice(list(clin.JEDICLINType))
|
||||
)
|
||||
|
@ -72,3 +72,34 @@ def test_clin_form_pop_dates_within_contract_dates():
|
||||
)
|
||||
valid_clin_form = CLINForm(obj=valid_clin)
|
||||
assert valid_clin_form.validate()
|
||||
|
||||
|
||||
def test_clin_form_obligated_greater_than_total():
|
||||
invalid_clin = factories.CLINFactory.create(
|
||||
total_amount=0,
|
||||
obligated_amount=1,
|
||||
start_date=datetime.date(2019, 9, 15),
|
||||
end_date=datetime.date(2020, 9, 14),
|
||||
)
|
||||
invalid_clin_form = CLINForm(obj=invalid_clin)
|
||||
assert not invalid_clin_form.validate()
|
||||
assert (
|
||||
translate("forms.task_order.clin_funding_errors.obligated_amount_error")
|
||||
) in invalid_clin_form.obligated_amount.errors
|
||||
|
||||
|
||||
def test_clin_form_dollar_amounts_out_of_range():
|
||||
invalid_clin = factories.CLINFactory.create(
|
||||
total_amount=-1,
|
||||
obligated_amount=1000000001,
|
||||
start_date=datetime.date(2019, 9, 15),
|
||||
end_date=datetime.date(2020, 9, 14),
|
||||
)
|
||||
invalid_clin_form = CLINForm(obj=invalid_clin)
|
||||
assert not invalid_clin_form.validate()
|
||||
assert (
|
||||
translate("forms.task_order.clin_funding_errors.funding_range_error")
|
||||
) in invalid_clin_form.total_amount.errors
|
||||
assert (
|
||||
translate("forms.task_order.clin_funding_errors.funding_range_error")
|
||||
) in invalid_clin_form.obligated_amount.errors
|
||||
|
@ -152,19 +152,23 @@ class TestBudget:
|
||||
|
||||
assert (
|
||||
to.total_contract_amount
|
||||
== clin1.obligated_amount + clin2.obligated_amount + clin3.obligated_amount
|
||||
== clin1.total_amount + clin2.total_amount + clin3.total_amount
|
||||
)
|
||||
|
||||
def test_total_obligated_funds(self):
|
||||
to = TaskOrder()
|
||||
clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4)
|
||||
assert to.total_obligated_funds == 0
|
||||
|
||||
clin1 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_1)
|
||||
clin2 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_2)
|
||||
clin3 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_3)
|
||||
clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4)
|
||||
assert (
|
||||
to.total_obligated_funds == clin1.obligated_amount + clin3.obligated_amount
|
||||
to.total_obligated_funds
|
||||
== clin1.obligated_amount
|
||||
+ clin2.obligated_amount
|
||||
+ clin3.obligated_amount
|
||||
+ clin4.obligated_amount
|
||||
)
|
||||
|
||||
|
||||
|
@ -194,11 +194,13 @@ def test_task_orders_submit_form_step_three_add_clins(client, user_session, task
|
||||
"clins-0-start_date": "01/01/2020",
|
||||
"clins-0-end_date": "01/01/2021",
|
||||
"clins-0-obligated_amount": "5000",
|
||||
"clins-0-total_amount": "10000",
|
||||
"clins-1-jedi_clin_type": "JEDI_CLIN_1",
|
||||
"clins-1-number": "12312",
|
||||
"clins-1-start_date": "01/01/2020",
|
||||
"clins-1-end_date": "01/01/2021",
|
||||
"clins-1-obligated_amount": "5000",
|
||||
"clins-1-total_amount": "5000",
|
||||
}
|
||||
response = client.post(
|
||||
url_for(
|
||||
@ -221,6 +223,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
|
||||
"start_date": "01/01/2020",
|
||||
"end_date": "01/01/2021",
|
||||
"obligated_amount": "5000",
|
||||
"total_amount": "10000",
|
||||
},
|
||||
{
|
||||
"jedi_clin_type": "JEDI_CLIN_1",
|
||||
@ -228,6 +231,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
|
||||
"start_date": "01/01/2020",
|
||||
"end_date": "01/01/2021",
|
||||
"obligated_amount": "5000",
|
||||
"total_amount": "10000",
|
||||
},
|
||||
]
|
||||
TaskOrders.create_clins(task_order.id, clin_list)
|
||||
@ -240,6 +244,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
|
||||
"clins-0-start_date": "01/01/2020",
|
||||
"clins-0-end_date": "01/01/2021",
|
||||
"clins-0-obligated_amount": "5000",
|
||||
"clins-0-total_amount": "10000",
|
||||
}
|
||||
response = client.post(
|
||||
url_for(
|
||||
|
@ -530,6 +530,7 @@ def test_task_orders_new_post_routes(post_url_assert_status):
|
||||
"clins-0-start_date": "01/01/2020",
|
||||
"clins-0-end_date": "01/01/2021",
|
||||
"clins-0-obligated_amount": "5000",
|
||||
"clins-0-total_amount": "10000",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
@ -205,6 +205,9 @@ forms:
|
||||
start: PoP start date must be on or after {date}.
|
||||
scope_description: 'What do you plan to do on the cloud? Some examples might include migrating an existing application or creating a prototype. You don’t need to include a detailed plan of execution, but should list key requirements. This section will be reviewed by your contracting officer, but won’t be sent to the CCPO. <p>Not sure how to describe your scope? <a href="#">Read some examples</a> to get some inspiration.</p>'
|
||||
scope_label: Cloud project scope
|
||||
clin_funding_errors:
|
||||
obligated_amount_error: Obligated amount must be less than or equal to total amount
|
||||
funding_range_error: Dollar amount must be from $0.00 to $1,000,000,000.00
|
||||
team_experience:
|
||||
built_1: Built, migrated, or consulted on 1-2 applications
|
||||
built_3: Built, migrated, or consulted on 3-5 applications
|
||||
@ -395,7 +398,7 @@ task_orders:
|
||||
type: CLIN Type
|
||||
idiq_clin_description: Description (IDIQ CLIN)
|
||||
pop: PoP
|
||||
amount: CLIN Value
|
||||
total_amount: CLIN Value
|
||||
obligated: Amount Obligated
|
||||
form:
|
||||
add_clin: Add another CLIN
|
||||
@ -406,7 +409,7 @@ task_orders:
|
||||
clin_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)."
|
||||
clin_details: CLIN Details
|
||||
clin_funding: CLIN Funding
|
||||
clin_number_label: CLIN Number
|
||||
clin_number_label: CLIN
|
||||
clin_type_label: Corresponding IDIQ CLIN
|
||||
clin_remove_text: 'Do you want to remove '
|
||||
clin_remove_confirm: Yes, remove CLIN
|
||||
@ -415,6 +418,7 @@ task_orders:
|
||||
cloud_funding_text: Data must match with what is in your uploaded document.
|
||||
draft_alert_title: Your information has been saved
|
||||
draft_alert_message: You can return to the Task Order Builder to enter missing information. Once you are finished, you’ll be ready to submit this request.
|
||||
total_funds_label: Total CLIN Value
|
||||
obligated_funds_label: Obligated Funds
|
||||
pop: Period of Performance
|
||||
pop_end: End Date
|
||||
|
Loading…
x
Reference in New Issue
Block a user