diff --git a/README.md b/README.md
index 7f6a28c2..ad2f1bb7 100644
--- a/README.md
+++ b/README.md
@@ -19,14 +19,15 @@ Before running the setup scripts, a couple of dependencies need to be installed
locally:
* `python` == 3.6
- Python version 3.6 must be installed on your machine before installing `pipenv`.
+ Python version 3.6 **must** be installed on your machine before installing `pipenv`.
You can download Python 3.6 [from python.org](https://www.python.org/downloads/)
- or use your preferred system package manager.
+ or use your preferred system package manager. Multiple versions of Python can exist on one
+ computer, but 3.6 is required for ATAT.
* `pipenv`
ATST requires `pipenv` to be installed for python dependency management. `pipenv`
will create the virtual environment that the app requires. [See
- `pipenv`'s documentation for instructions on installing `pipenv](
+ `pipenv`'s documentation for instructions on installing `pipenv`](
https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv).
* `yarn`
@@ -35,7 +36,10 @@ locally:
* `postgres` >= 9.6
ATST requires a PostgreSQL instance (>= 9.6) for persistence. Have PostgresSQL installed
- and running on the default port of 5432. You can verify that PostgresSQL is running
+ and running on the default port of 5432. (A good resource for installing and running
+ PostgreSQL for Macs is [Postgres.app](https://postgresapp.com/). Follow the instructions,
+ including the optional Step 3, and add `/Applications/Postgres.app/Contents/Versions/latest/bin`
+ to your `PATH` environment variable.) You can verify that PostgresSQL is running
by executing `psql` and ensuring that a connection is successfully made.
* `redis`
diff --git a/alembic/versions/1f690989e38e_add_pdf_to_task_order.py b/alembic/versions/1f690989e38e_add_pdf_to_task_order.py
new file mode 100644
index 00000000..c22926da
--- /dev/null
+++ b/alembic/versions/1f690989e38e_add_pdf_to_task_order.py
@@ -0,0 +1,36 @@
+"""Add PDF to Task Order
+
+Revision ID: 1f690989e38e
+Revises: 0ff4c31c4d28
+Create Date: 2019-02-04 15:56:57.642156
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '1f690989e38e'
+down_revision = '0ff4c31c4d28'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('task_orders', sa.Column('pdf_attachment_id', postgresql.UUID(as_uuid=True), nullable=True))
+ op.drop_constraint('task_orders_attachments_attachment_id', 'task_orders', type_='foreignkey')
+ op.alter_column('task_orders', 'attachment_id', new_column_name='csp_attachment_id')
+ op.create_foreign_key('task_orders_attachments_pdf_attachment_id', 'task_orders', 'attachments', ['pdf_attachment_id'], ['id'])
+ op.create_foreign_key('task_orders_attachments_csp_attachment_id', 'task_orders', 'attachments', ['csp_attachment_id'], ['id'])
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint('task_orders_attachments_csp_attachment_id', 'task_orders', type_='foreignkey')
+ op.drop_constraint('task_orders_attachments_pdf_attachment_id', 'task_orders', type_='foreignkey')
+ op.alter_column('task_orders', 'csp_attachment_id', new_column_name='attachment_id')
+ op.create_foreign_key('task_orders_attachments_attachment_id', 'task_orders', 'attachments', ['attachment_id'], ['id'])
+ op.drop_column('task_orders', 'pdf_attachment_id')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py
new file mode 100644
index 00000000..8fd1930d
--- /dev/null
+++ b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py
@@ -0,0 +1,34 @@
+"""Record signer DOD ID
+
+Revision ID: b3a1a07cf30b
+Revises: c98adf9bb431
+Create Date: 2019-02-12 10:16:19.349083
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b3a1a07cf30b'
+down_revision = 'c98adf9bb431'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('task_orders', sa.Column('level_of_warrant', sa.Numeric(scale=2), nullable=True))
+ op.add_column('task_orders', sa.Column('signed_at', sa.DateTime(), nullable=True))
+ op.add_column('task_orders', sa.Column('signer_dod_id', sa.String(), nullable=True))
+ op.add_column('task_orders', sa.Column('unlimited_level_of_warrant', sa.Boolean(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('task_orders', 'unlimited_level_of_warrant')
+ op.drop_column('task_orders', 'signer_dod_id')
+ op.drop_column('task_orders', 'signed_at')
+ op.drop_column('task_orders', 'level_of_warrant')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/c98adf9bb431_record_invitation_status.py b/alembic/versions/c98adf9bb431_record_invitation_status.py
new file mode 100644
index 00000000..f017beef
--- /dev/null
+++ b/alembic/versions/c98adf9bb431_record_invitation_status.py
@@ -0,0 +1,32 @@
+"""record invitation status
+
+Revision ID: c98adf9bb431
+Revises: 1f690989e38e
+Create Date: 2019-02-06 09:02:28.617202
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c98adf9bb431'
+down_revision = '1f690989e38e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('task_orders', sa.Column('cor_invite', sa.Boolean(), nullable=True))
+ op.add_column('task_orders', sa.Column('ko_invite', sa.Boolean(), nullable=True))
+ op.add_column('task_orders', sa.Column('so_invite', sa.Boolean(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('task_orders', 'so_invite')
+ op.drop_column('task_orders', 'ko_invite')
+ op.drop_column('task_orders', 'cor_invite')
+ # ### end Alembic commands ###
diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py
index 3611a45c..fd498227 100644
--- a/atst/domain/csp/reports.py
+++ b/atst/domain/csp/reports.py
@@ -1,3 +1,4 @@
+import datetime
from itertools import groupby
from collections import OrderedDict
import pendulum
@@ -31,131 +32,152 @@ class MockApplication:
self.environments = [make_env(env_name) for env_name in envs]
+def generate_sample_dates(_max=8):
+ current = datetime.datetime.today()
+ sample_dates = []
+ for _i in range(_max):
+ current = current - datetime.timedelta(days=29)
+ sample_dates.append(current.strftime("%m/%Y"))
+
+ reversed(sample_dates)
+ return sample_dates
+
+
class MockReportingProvider(ReportingInterface):
+ FIXTURE_MONTHS = generate_sample_dates()
+
MONTHLY_SPEND_BY_ENVIRONMENT = {
"LC04_Integ": {
- "02/2018": 284,
- "03/2018": 1210,
- "04/2018": 1430,
- "05/2018": 1366,
- "06/2018": 1169,
- "07/2018": 991,
- "08/2018": 978,
- "09/2018": 737,
+ FIXTURE_MONTHS[7]: 284,
+ FIXTURE_MONTHS[6]: 1210,
+ FIXTURE_MONTHS[5]: 1430,
+ FIXTURE_MONTHS[4]: 1366,
+ FIXTURE_MONTHS[3]: 1169,
+ FIXTURE_MONTHS[2]: 991,
+ FIXTURE_MONTHS[1]: 978,
+ FIXTURE_MONTHS[0]: 737,
},
"LC04_PreProd": {
- "02/2018": 812,
- "03/2018": 1389,
- "04/2018": 1425,
- "05/2018": 1306,
- "06/2018": 1112,
- "07/2018": 936,
- "08/2018": 921,
- "09/2018": 694,
+ FIXTURE_MONTHS[7]: 812,
+ FIXTURE_MONTHS[6]: 1389,
+ FIXTURE_MONTHS[5]: 1425,
+ FIXTURE_MONTHS[4]: 1306,
+ FIXTURE_MONTHS[3]: 1112,
+ FIXTURE_MONTHS[2]: 936,
+ FIXTURE_MONTHS[1]: 921,
+ FIXTURE_MONTHS[0]: 694,
},
"LC04_Prod": {
- "02/2018": 1742,
- "03/2018": 1716,
- "04/2018": 1866,
- "05/2018": 1809,
- "06/2018": 1839,
- "07/2018": 1633,
- "08/2018": 1654,
- "09/2018": 1103,
+ FIXTURE_MONTHS[7]: 1742,
+ FIXTURE_MONTHS[6]: 1716,
+ FIXTURE_MONTHS[5]: 1866,
+ FIXTURE_MONTHS[4]: 1809,
+ FIXTURE_MONTHS[3]: 1839,
+ FIXTURE_MONTHS[2]: 1633,
+ FIXTURE_MONTHS[1]: 1654,
+ FIXTURE_MONTHS[0]: 1103,
},
"SF18_Integ": {
- "04/2018": 1498,
- "05/2018": 1400,
- "06/2018": 1394,
- "07/2018": 1171,
- "08/2018": 1200,
- "09/2018": 963,
+ FIXTURE_MONTHS[5]: 1498,
+ FIXTURE_MONTHS[4]: 1400,
+ FIXTURE_MONTHS[3]: 1394,
+ FIXTURE_MONTHS[2]: 1171,
+ FIXTURE_MONTHS[1]: 1200,
+ FIXTURE_MONTHS[0]: 963,
},
"SF18_PreProd": {
- "04/2018": 1780,
- "05/2018": 1667,
- "06/2018": 1703,
- "07/2018": 1474,
- "08/2018": 1441,
- "09/2018": 933,
+ FIXTURE_MONTHS[5]: 1780,
+ FIXTURE_MONTHS[4]: 1667,
+ FIXTURE_MONTHS[3]: 1703,
+ FIXTURE_MONTHS[2]: 1474,
+ FIXTURE_MONTHS[1]: 1441,
+ FIXTURE_MONTHS[0]: 933,
},
"SF18_Prod": {
- "04/2018": 1686,
- "05/2018": 1779,
- "06/2018": 1792,
- "07/2018": 1570,
- "08/2018": 1539,
- "09/2018": 986,
+ FIXTURE_MONTHS[5]: 1686,
+ FIXTURE_MONTHS[4]: 1779,
+ FIXTURE_MONTHS[3]: 1792,
+ FIXTURE_MONTHS[2]: 1570,
+ FIXTURE_MONTHS[1]: 1539,
+ FIXTURE_MONTHS[0]: 986,
},
"Canton_Prod": {
- "05/2018": 28699,
- "06/2018": 26766,
- "07/2018": 22619,
- "08/2018": 24090,
- "09/2018": 16719,
+ FIXTURE_MONTHS[4]: 28699,
+ FIXTURE_MONTHS[3]: 26766,
+ FIXTURE_MONTHS[2]: 22619,
+ FIXTURE_MONTHS[1]: 24090,
+ FIXTURE_MONTHS[0]: 16719,
},
"BD04_Integ": {},
"BD04_PreProd": {
- "02/2018": 7019,
- "03/2018": 3004,
- "04/2018": 2691,
- "05/2018": 2901,
- "06/2018": 3463,
- "07/2018": 3314,
- "08/2018": 3432,
- "09/2018": 723,
+ FIXTURE_MONTHS[7]: 7019,
+ FIXTURE_MONTHS[6]: 3004,
+ FIXTURE_MONTHS[5]: 2691,
+ FIXTURE_MONTHS[4]: 2901,
+ FIXTURE_MONTHS[3]: 3463,
+ FIXTURE_MONTHS[2]: 3314,
+ FIXTURE_MONTHS[1]: 3432,
+ FIXTURE_MONTHS[0]: 723,
},
- "SCV18_Dev": {"05/2019": 9797},
+ "SCV18_Dev": {FIXTURE_MONTHS[1]: 9797},
"Crown_CR Portal Dev": {
- "03/2018": 208,
- "04/2018": 457,
- "05/2018": 671,
- "06/2018": 136,
- "07/2018": 1524,
- "08/2018": 2077,
- "09/2018": 1858,
+ FIXTURE_MONTHS[6]: 208,
+ FIXTURE_MONTHS[5]: 457,
+ FIXTURE_MONTHS[4]: 671,
+ FIXTURE_MONTHS[3]: 136,
+ FIXTURE_MONTHS[2]: 1524,
+ FIXTURE_MONTHS[1]: 2077,
+ FIXTURE_MONTHS[0]: 1858,
},
"Crown_CR Staging": {
- "03/2018": 208,
- "04/2018": 457,
- "05/2018": 671,
- "06/2018": 136,
- "07/2018": 1524,
- "08/2018": 2077,
- "09/2018": 1858,
+ FIXTURE_MONTHS[6]: 208,
+ FIXTURE_MONTHS[5]: 457,
+ FIXTURE_MONTHS[4]: 671,
+ FIXTURE_MONTHS[3]: 136,
+ FIXTURE_MONTHS[2]: 1524,
+ FIXTURE_MONTHS[1]: 2077,
+ FIXTURE_MONTHS[0]: 1858,
+ },
+ "Crown_CR Portal Test 1": {
+ FIXTURE_MONTHS[2]: 806,
+ FIXTURE_MONTHS[1]: 1966,
+ FIXTURE_MONTHS[0]: 2597,
+ },
+ "Crown_Jewels Prod": {
+ FIXTURE_MONTHS[2]: 806,
+ FIXTURE_MONTHS[1]: 1966,
+ FIXTURE_MONTHS[0]: 2597,
},
- "Crown_CR Portal Test 1": {"07/2018": 806, "08/2018": 1966, "09/2018": 2597},
- "Crown_Jewels Prod": {"07/2018": 806, "08/2018": 1966, "09/2018": 2597},
"Crown_Jewels Dev": {
- "03/2018": 145,
- "04/2018": 719,
- "05/2018": 1243,
- "06/2018": 2214,
- "07/2018": 2959,
- "08/2018": 4151,
- "09/2018": 4260,
+ FIXTURE_MONTHS[6]: 145,
+ FIXTURE_MONTHS[5]: 719,
+ FIXTURE_MONTHS[4]: 1243,
+ FIXTURE_MONTHS[3]: 2214,
+ FIXTURE_MONTHS[2]: 2959,
+ FIXTURE_MONTHS[1]: 4151,
+ FIXTURE_MONTHS[0]: 4260,
},
- "NP02_Integ": {"08/2018": 284, "09/2018": 1210},
- "NP02_PreProd": {"08/2018": 812, "09/2018": 1389},
- "NP02_Prod": {"08/2018": 3742, "09/2018": 4716},
- "FM_Integ": {"08/2018": 1498},
- "FM_Prod": {"09/2018": 5686},
+ "NP02_Integ": {FIXTURE_MONTHS[1]: 284, FIXTURE_MONTHS[0]: 1210},
+ "NP02_PreProd": {FIXTURE_MONTHS[1]: 812, FIXTURE_MONTHS[0]: 1389},
+ "NP02_Prod": {FIXTURE_MONTHS[1]: 3742, FIXTURE_MONTHS[0]: 4716},
+ "FM_Integ": {FIXTURE_MONTHS[1]: 1498},
+ "FM_Prod": {FIXTURE_MONTHS[0]: 5686},
}
CUMULATIVE_BUDGET_AARDVARK = {
- "02/2018": {"spend": 9857, "cumulative": 9857},
- "03/2018": {"spend": 7881, "cumulative": 17738},
- "04/2018": {"spend": 14010, "cumulative": 31748},
- "05/2018": {"spend": 43510, "cumulative": 75259},
- "06/2018": {"spend": 41725, "cumulative": 116_984},
- "07/2018": {"spend": 41328, "cumulative": 158_312},
- "08/2018": {"spend": 47491, "cumulative": 205_803},
- "09/2018": {"spend": 36028, "cumulative": 241_831},
+ FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857},
+ FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738},
+ FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748},
+ FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259},
+ FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984},
+ FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312},
+ FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803},
+ FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831},
}
CUMULATIVE_BUDGET_BELUGA = {
- "08/2018": {"spend": 4838, "cumulative": 4838},
- "09/2018": {"spend": 14500, "cumulative": 19338},
+ FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838},
+ FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338},
}
REPORT_FIXTURE_MAP = {
diff --git a/atst/filters.py b/atst/filters.py
index 61c42f8c..011d930b 100644
--- a/atst/filters.py
+++ b/atst/filters.py
@@ -19,7 +19,19 @@ def dollars(value):
return "${:,.2f}".format(numberValue)
+def justDollars(value):
+ raw = dollars(value)
+ return raw.split(".")[0]
+
+
+def justCents(value):
+ raw = dollars(value)
+ return raw.split(".")[1]
+
+
def usPhone(number):
+ if not number:
+ return ""
phone = re.sub(r"\D", "", number)
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:])
@@ -99,6 +111,8 @@ def normalizeOrder(title):
def register_filters(app):
app.jinja_env.filters["iconSvg"] = iconSvg
app.jinja_env.filters["dollars"] = dollars
+ app.jinja_env.filters["justDollars"] = justDollars
+ app.jinja_env.filters["justCents"] = justCents
app.jinja_env.filters["usPhone"] = usPhone
app.jinja_env.filters["readableInteger"] = readableInteger
app.jinja_env.filters["getOptionLabel"] = getOptionLabel
diff --git a/atst/forms/officers.py b/atst/forms/officers.py
index 1bbb14e3..b0b0e61d 100644
--- a/atst/forms/officers.py
+++ b/atst/forms/officers.py
@@ -1,5 +1,5 @@
from flask_wtf import FlaskForm
-from wtforms.fields import StringField
+from wtforms.fields import StringField, BooleanField
from wtforms.fields.html5 import TelField
from wtforms.validators import Email, Length, Optional
@@ -15,6 +15,7 @@ class OfficerForm(FlaskForm):
email = StringField("Email", validators=[Optional(), Email()])
phone_number = TelField("Phone Number", validators=[PhoneNumber()])
dod_id = StringField("DoD ID", validators=[Optional(), Length(min=10), IsNumber()])
+ invite = BooleanField("Invite to Task Order Builder")
class EditTaskOrderOfficersForm(CacheableForm):
diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py
index 7b5f42e4..b731e2bd 100644
--- a/atst/forms/task_order.py
+++ b/atst/forms/task_order.py
@@ -222,3 +222,28 @@ class OversightForm(CacheableForm):
class ReviewForm(CacheableForm):
pass
+
+
+class SignatureForm(CacheableForm):
+ level_of_warrant = DecimalField(
+ translate("task_orders.sign.level_of_warrant_label"),
+ description=translate("task_orders.sign.level_of_warrant_description"),
+ validators=[
+ RequiredIf(
+ lambda form: (
+ form._fields.get("unlimited_level_of_warrant").data is not True
+ )
+ )
+ ],
+ )
+
+ unlimited_level_of_warrant = BooleanField(
+ translate("task_orders.sign.unlimited_level_of_warrant_description"),
+ validators=[Optional()],
+ )
+
+ signature = BooleanField(
+ translate("task_orders.sign.digital_signature_label"),
+ description=translate("task_orders.sign.digital_signature_description"),
+ validators=[Required()],
+ )
diff --git a/atst/models/application.py b/atst/models/application.py
index 02c7185a..050b13d7 100644
--- a/atst/models/application.py
+++ b/atst/models/application.py
@@ -17,6 +17,14 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
portfolio = relationship("Portfolio")
environments = relationship("Environment", back_populates="application")
+ @property
+ def users(self):
+ return set([user for env in self.environments for user in env.users])
+
+ @property
+ def num_users(self):
+ return len(self.users)
+
@property
def displayname(self):
return self.name
diff --git a/atst/models/task_order.py b/atst/models/task_order.py
index 6ea523e2..0e58ed60 100644
--- a/atst/models/task_order.py
+++ b/atst/models/task_order.py
@@ -2,7 +2,16 @@ from enum import Enum
from datetime import date
import pendulum
-from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer
+from sqlalchemy import (
+ Column,
+ Numeric,
+ String,
+ ForeignKey,
+ Date,
+ Integer,
+ DateTime,
+ Boolean,
+)
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship
@@ -12,6 +21,7 @@ from atst.models import Attachment, Base, types, mixins
class Status(Enum):
+ STARTED = "Started"
PENDING = "Pending"
ACTIVE = "Active"
EXPIRED = "Expired"
@@ -51,8 +61,8 @@ class TaskOrder(Base, mixins.TimestampsMixin):
start_date = Column(Date) # Period of Performance
end_date = Column(Date)
performance_length = Column(Integer)
- attachment_id = Column(ForeignKey("attachments.id"))
- _csp_estimate = relationship("Attachment")
+ csp_attachment_id = Column(ForeignKey("attachments.id"))
+ _csp_estimate = relationship("Attachment", foreign_keys=[csp_attachment_id])
clin_01 = Column(Numeric(scale=2))
clin_02 = Column(Numeric(scale=2))
clin_03 = Column(Numeric(scale=2))
@@ -62,19 +72,28 @@ class TaskOrder(Base, mixins.TimestampsMixin):
ko_email = Column(String) # Email
ko_phone_number = Column(String) # Phone Number
ko_dod_id = Column(String) # DOD ID
+ ko_invite = Column(Boolean)
cor_first_name = Column(String) # First Name
cor_last_name = Column(String) # Last Name
cor_email = Column(String) # Email
cor_phone_number = Column(String) # Phone Number
cor_dod_id = Column(String) # DOD ID
+ cor_invite = Column(Boolean)
so_first_name = Column(String) # First Name
so_last_name = Column(String) # Last Name
so_email = Column(String) # Email
so_phone_number = Column(String) # Phone Number
so_dod_id = Column(String) # DOD ID
+ so_invite = Column(Boolean)
+ pdf_attachment_id = Column(ForeignKey("attachments.id"))
+ _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
number = Column(String, unique=True) # Task Order Number
loa = Column(String) # Line of Accounting (LOA)
custom_clauses = Column(String) # Custom Clauses
+ signer_dod_id = Column(String)
+ signed_at = Column(DateTime)
+ level_of_warrant = Column(Numeric(scale=2))
+ unlimited_level_of_warrant = Column(Boolean)
@hybrid_property
def csp_estimate(self):
@@ -82,26 +101,38 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@csp_estimate.setter
def csp_estimate(self, new_csp_estimate):
- if isinstance(new_csp_estimate, Attachment):
- self._csp_estimate = new_csp_estimate
- elif isinstance(new_csp_estimate, FileStorage):
- self._csp_estimate = Attachment.attach(
- new_csp_estimate, "task_order", self.id
- )
- elif not new_csp_estimate and self._csp_estimate:
- self._csp_estimate = None
- elif new_csp_estimate:
- raise TypeError("Could not set csp_estimate with invalid type")
+ self._csp_estimate = self._set_attachment(new_csp_estimate, "_csp_estimate")
+
+ @hybrid_property
+ def pdf(self):
+ return self._pdf
+
+ @pdf.setter
+ def pdf(self, new_pdf):
+ self._pdf = self._set_attachment(new_pdf, "_pdf")
+
+ def _set_attachment(self, new_attachment, attribute):
+ if isinstance(new_attachment, Attachment):
+ return new_attachment
+ elif isinstance(new_attachment, FileStorage):
+ return Attachment.attach(new_attachment, "task_order", self.id)
+ elif not new_attachment and hasattr(self, attribute):
+ return None
+ else:
+ raise TypeError("Could not set attachment with invalid type")
@property
def is_submitted(self):
-
return (
self.number is not None
and self.start_date is not None
and self.end_date is not None
)
+ @property
+ def is_active(self):
+ return self.status == Status.ACTIVE
+
@property
def status(self):
if self.is_submitted:
@@ -112,7 +143,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
return Status.EXPIRED
return Status.ACTIVE
else:
- return Status.PENDING
+ return Status.STARTED
@property
def display_status(self):
@@ -142,6 +173,44 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_pending(self):
return self.status == Status.PENDING
+ @property
+ def ko_invitable(self):
+ """
+ The MO has indicated that the KO should be invited but we have not sent
+ an invite and attached the KO user
+ """
+ return self.ko_invite and not self.contracting_officer
+
+ @property
+ def cor_invitable(self):
+ """
+ The MO has indicated that the COR should be invited but we have not sent
+ an invite and attached the COR user
+ """
+ return self.cor_invite and not self.contracting_officer_representative
+
+ @property
+ def so_invitable(self):
+ """
+ The MO has indicated that the SO should be invited but we have not sent
+ an invite and attached the SO user
+ """
+ return self.so_invite and not self.security_officer
+
+ _OFFICER_PREFIXES = {
+ "contracting_officer": "ko",
+ "contracting_officer_representative": "cor",
+ "security_officer": "so",
+ }
+ _OFFICER_PROPERTIES = ["first_name", "last_name", "phone_number", "email", "dod_id"]
+
+ def officer_dictionary(self, officer_type):
+ prefix = self._OFFICER_PREFIXES[officer_type]
+ return {
+ field: getattr(self, "{}_{}".format(prefix, field))
+ for field in self._OFFICER_PROPERTIES
+ }
+
def to_dictionary(self):
return {
"portfolio_name": self.portfolio_name,
diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py
index c1acc655..4f0e91cb 100644
--- a/atst/routes/__init__.py
+++ b/atst/routes/__init__.py
@@ -1,5 +1,14 @@
import urllib.parse as url
-from flask import Blueprint, render_template, g, redirect, session, url_for, request
+from flask import (
+ Blueprint,
+ render_template,
+ g,
+ redirect,
+ session,
+ url_for,
+ request,
+ make_response,
+)
from flask import current_app as app
from jinja2.exceptions import TemplateNotFound
@@ -56,7 +65,7 @@ def home():
num_portfolios = len([role for role in user.portfolio_roles if role.is_active])
if num_portfolios == 0:
- return redirect(url_for("requests.requests_index"))
+ return redirect(url_for("portfolios.portfolios"))
elif num_portfolios == 1:
portfolio_role = user.portfolio_roles[0]
portfolio_id = portfolio_role.portfolio.id
@@ -131,7 +140,9 @@ def login_redirect():
@bp.route("/logout")
def logout():
_logout()
- return redirect(url_for(".root"))
+ response = make_response(redirect(url_for(".root")))
+ response.set_cookie("expandSidenav", "", expires=0)
+ return response
@bp.route("/activity-history")
diff --git a/atst/routes/portfolios/__init__.py b/atst/routes/portfolios/__init__.py
index ad935378..e735064f 100644
--- a/atst/routes/portfolios/__init__.py
+++ b/atst/routes/portfolios/__init__.py
@@ -1,4 +1,5 @@
from flask import Blueprint, request as http_request, g, render_template
+from operator import attrgetter
portfolios_bp = Blueprint("portfolios", __name__)
@@ -31,4 +32,24 @@ def portfolio():
)
return False
- return {"portfolio": portfolio, "permissions": Permissions, "user_can": user_can}
+ if not portfolio is None:
+ active_task_orders = [
+ task_order for task_order in portfolio.task_orders if task_order.is_active
+ ]
+ funding_end_date = (
+ sorted(active_task_orders, key=attrgetter("end_date"))[-1].end_date
+ if active_task_orders
+ else None
+ )
+ funded = len(active_task_orders) > 1
+ else:
+ funding_end_date = None
+ funded = None
+
+ return {
+ "portfolio": portfolio,
+ "permissions": Permissions,
+ "user_can": user_can,
+ "funding_end_date": funding_end_date,
+ "funded": funded,
+ }
diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py
index 647d68be..6be58772 100644
--- a/atst/routes/portfolios/index.py
+++ b/atst/routes/portfolios/index.py
@@ -15,14 +15,27 @@ from atst.models.permissions import Permissions
@portfolios_bp.route("/portfolios")
def portfolios():
portfolios = Portfolios.for_user(g.current_user)
- return render_template("portfolios/index.html", page=5, portfolios=portfolios)
+
+ if portfolios:
+ return render_template("portfolios/index.html", page=5, portfolios=portfolios)
+ else:
+ return render_template("portfolios/blank_slate.html")
-@portfolios_bp.route("/portfolios//edit")
-def portfolio(portfolio_id):
+@portfolios_bp.route("/portfolios//admin")
+def portfolio_admin(portfolio_id):
portfolio = Portfolios.get_for_update_information(g.current_user, portfolio_id)
form = PortfolioForm(data={"name": portfolio.name})
- return render_template("portfolios/edit.html", form=form, portfolio=portfolio)
+ pagination_opts = Paginator.get_pagination_opts(http_request)
+ audit_events = AuditLog.get_portfolio_events(
+ g.current_user, portfolio, pagination_opts
+ )
+ return render_template(
+ "portfolios/admin.html",
+ form=form,
+ portfolio=portfolio,
+ audit_events=audit_events,
+ )
@portfolios_bp.route("/portfolios//edit", methods=["POST"])
@@ -62,9 +75,11 @@ def portfolio_reports(portfolio_id):
prev_month = current_month - timedelta(days=28)
two_months_ago = prev_month - timedelta(days=28)
- expiration_date = (
- portfolio.legacy_task_order and portfolio.legacy_task_order.expiration_date
+ task_order = next(
+ (task_order for task_order in portfolio.task_orders if task_order.is_active),
+ None,
)
+ expiration_date = task_order and task_order.end_date
if expiration_date:
remaining_difference = expiration_date - today
remaining_days = remaining_difference.days
@@ -76,8 +91,7 @@ def portfolio_reports(portfolio_id):
cumulative_budget=Reports.cumulative_budget(portfolio),
portfolio_totals=Reports.portfolio_totals(portfolio),
monthly_totals=Reports.monthly_totals(portfolio),
- jedi_request=portfolio.request,
- legacy_task_order=portfolio.legacy_task_order,
+ task_order=task_order,
current_month=current_month,
prev_month=prev_month,
two_months_ago=two_months_ago,
diff --git a/atst/routes/portfolios/members.py b/atst/routes/portfolios/members.py
index e549cdf2..b61510e6 100644
--- a/atst/routes/portfolios/members.py
+++ b/atst/routes/portfolios/members.py
@@ -23,26 +23,25 @@ from atst.models.permissions import Permissions
from atst.utils.flash import formatted_flash as flash
+def serialize_portfolio_role(portfolio_role):
+ return {
+ "name": portfolio_role.user_name,
+ "status": portfolio_role.display_status,
+ "id": portfolio_role.user_id,
+ "role": portfolio_role.role_displayname,
+ "num_env": portfolio_role.num_environment_roles,
+ "edit_link": url_for(
+ "portfolios.view_member",
+ portfolio_id=portfolio_role.portfolio_id,
+ member_id=portfolio_role.user_id,
+ ),
+ }
+
+
@portfolios_bp.route("/portfolios//members")
def portfolio_members(portfolio_id):
portfolio = Portfolios.get_with_members(g.current_user, portfolio_id)
- new_member_name = http_request.args.get("newMemberName")
- new_member = next(
- filter(lambda m: m.user_name == new_member_name, portfolio.members), None
- )
- members_list = [
- {
- "name": k.user_name,
- "status": k.display_status,
- "id": k.user_id,
- "role": k.role_displayname,
- "num_env": k.num_environment_roles,
- "edit_link": url_for(
- "portfolios.view_member", portfolio_id=portfolio.id, member_id=k.user_id
- ),
- }
- for k in portfolio.members
- ]
+ members_list = [serialize_portfolio_role(k) for k in portfolio.members]
return render_template(
"portfolios/members/index.html",
@@ -50,7 +49,21 @@ def portfolio_members(portfolio_id):
role_choices=PORTFOLIO_ROLE_DEFINITIONS,
status_choices=MEMBER_STATUS_CHOICES,
members=members_list,
- new_member=new_member,
+ )
+
+
+@portfolios_bp.route("/portfolios//applications//members")
+def application_members(portfolio_id, application_id):
+ portfolio = Portfolios.get_with_members(g.current_user, portfolio_id)
+ application = Applications.get(g.current_user, portfolio, application_id)
+ # TODO: this should show only members that have env roles in this application
+ members_list = [serialize_portfolio_role(k) for k in portfolio.members]
+
+ return render_template(
+ "portfolios/applications/members.html",
+ portfolio=portfolio,
+ application=application,
+ members=members_list,
)
@@ -76,7 +89,7 @@ def create_member(portfolio_id):
)
invite_service.invite()
- flash("new_portfolio_member", new_member=new_member, portfolio=portfolio)
+ flash("new_portfolio_member", new_member=member, portfolio=portfolio)
return redirect(
url_for("portfolios.portfolio_members", portfolio_id=portfolio.id)
diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py
index 70c30a33..15dbdd03 100644
--- a/atst/routes/portfolios/task_orders.py
+++ b/atst/routes/portfolios/task_orders.py
@@ -1,5 +1,4 @@
from collections import defaultdict
-from operator import itemgetter
from flask import g, redirect, render_template, url_for, request as http_request
@@ -41,22 +40,17 @@ def portfolio_funding(portfolio_id):
task_orders_by_status[task_order.status].append(serialized_task_order)
active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, [])
- funding_end_date = (
- sorted(active_task_orders, key=itemgetter("end_date"))[-1]["end_date"]
- if active_task_orders
- else None
- )
- funded = len(active_task_orders) > 1
total_balance = sum([task_order["balance"] for task_order in active_task_orders])
return render_template(
"portfolios/task_orders/index.html",
portfolio=portfolio,
- pending_task_orders=task_orders_by_status.get(TaskOrderStatus.PENDING, []),
+ pending_task_orders=(
+ task_orders_by_status.get(TaskOrderStatus.STARTED, [])
+ + task_orders_by_status.get(TaskOrderStatus.PENDING, [])
+ ),
active_task_orders=active_task_orders,
expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []),
- funding_end_date=funding_end_date,
- funded=funded,
total_balance=total_balance,
)
@@ -101,11 +95,7 @@ def submit_ko_review(portfolio_id, task_order_id, form=None):
if form.validate():
TaskOrders.update(user=g.current_user, task_order=task_order, **form.data)
return redirect(
- url_for(
- "portfolios.view_task_order",
- portfolio_id=portfolio_id,
- task_order_id=task_order_id,
- )
+ url_for("task_orders.signature_requested", task_order_id=task_order_id)
)
else:
return render_template(
diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py
index 15395177..e09d7c91 100644
--- a/atst/routes/task_orders/__init__.py
+++ b/atst/routes/task_orders/__init__.py
@@ -5,3 +5,4 @@ task_orders_bp = Blueprint("task_orders", __name__)
from . import new
from . import index
from . import invite
+from . import signing
diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py
index 6abf34fd..86f25f91 100644
--- a/atst/routes/task_orders/index.py
+++ b/atst/routes/task_orders/index.py
@@ -20,20 +20,29 @@ def download_summary(task_order_id):
)
+def send_file(attachment):
+ generator = app.csp.files.download(attachment.object_name)
+ return Response(
+ generator,
+ headers={
+ "Content-Disposition": "attachment; filename={}".format(attachment.filename)
+ },
+ )
+
+
@task_orders_bp.route("/task_orders/csp_estimate/")
def download_csp_estimate(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
if task_order.csp_estimate:
- estimate = task_order.csp_estimate
- generator = app.csp.files.download(estimate.object_name)
- return Response(
- generator,
- headers={
- "Content-Disposition": "attachment; filename={}".format(
- estimate.filename
- )
- },
- )
-
+ return send_file(task_order.csp_estimate)
else:
raise NotFoundError("task_order CSP estimate")
+
+
+@task_orders_bp.route("/task_orders/pdf/")
+def download_task_order_pdf(task_order_id):
+ task_order = TaskOrders.get(g.current_user, task_order_id)
+ if task_order.pdf:
+ return send_file(task_order.pdf)
+ else:
+ raise NotFoundError("task_order pdf")
diff --git a/atst/routes/task_orders/invite.py b/atst/routes/task_orders/invite.py
index ac40e0c2..8d453951 100644
--- a/atst/routes/task_orders/invite.py
+++ b/atst/routes/task_orders/invite.py
@@ -3,17 +3,68 @@ from flask import redirect, url_for, g
from . import task_orders_bp
from atst.domain.task_orders import TaskOrders
from atst.utils.flash import formatted_flash as flash
+from atst.domain.portfolio_roles import PortfolioRoles
+from atst.services.invitation import Invitation as InvitationService
+
+
+OFFICER_INVITATIONS = [
+ {
+ "field": "ko_invite",
+ "role": "contracting_officer",
+ "subject": "Review a task order",
+ "template": "emails/invitation.txt",
+ },
+ {
+ "field": "cor_invite",
+ "role": "contracting_officer_representative",
+ "subject": "Help with a task order",
+ "template": "emails/invitation.txt",
+ },
+ {
+ "field": "so_invite",
+ "role": "security_officer",
+ "subject": "Review security for a task order",
+ "template": "emails/invitation.txt",
+ },
+]
+
+
+def update_officer_invitations(user, task_order):
+ for officer_type in OFFICER_INVITATIONS:
+ field = officer_type["field"]
+ if getattr(task_order, field) and not getattr(task_order, officer_type["role"]):
+ officer_data = task_order.officer_dictionary(officer_type["role"])
+ officer = TaskOrders.add_officer(
+ user, task_order, officer_type["role"], officer_data
+ )
+ pf_officer_member = PortfolioRoles.get(task_order.portfolio.id, officer.id)
+ invite_service = InvitationService(
+ user,
+ pf_officer_member,
+ officer_data["email"],
+ subject=officer_type["subject"],
+ email_template=officer_type["template"],
+ )
+ invite_service.invite()
@task_orders_bp.route("/task_orders/invite/", methods=["POST"])
def invite(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
- portfolio = task_order.portfolio
- flash("task_order_congrats", portfolio=portfolio)
- return redirect(
- url_for(
- "portfolios.view_task_order",
- portfolio_id=task_order.portfolio_id,
- task_order_id=task_order.id,
+ if TaskOrders.all_sections_complete(task_order):
+ update_officer_invitations(g.current_user, task_order)
+
+ portfolio = task_order.portfolio
+ flash("task_order_congrats", portfolio=portfolio)
+ return redirect(
+ url_for(
+ "portfolios.view_task_order",
+ portfolio_id=task_order.portfolio_id,
+ task_order_id=task_order.id,
+ )
+ )
+ else:
+ flash("task_order_incomplete")
+ return redirect(
+ url_for("task_orders.new", screen=4, task_order_id=task_order.id)
)
- )
diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py
index 8892ba05..894885e9 100644
--- a/atst/routes/task_orders/new.py
+++ b/atst/routes/task_orders/new.py
@@ -12,9 +12,7 @@ from flask import (
from . import task_orders_bp
from atst.domain.task_orders import TaskOrders
from atst.domain.portfolios import Portfolios
-from atst.domain.portfolio_roles import PortfolioRoles
import atst.forms.task_order as task_order_form
-from atst.services.invitation import Invitation as InvitationService
TASK_ORDER_SECTIONS = [
@@ -173,7 +171,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
def validate(self):
return self.form.validate()
- def _update_task_order(self):
+ def update(self):
if self.task_order:
if "portfolio_name" in self.form.data:
new_name = self.form.data["portfolio_name"]
@@ -189,65 +187,6 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
self._task_order = TaskOrders.create(portfolio=pf, creator=self.user)
TaskOrders.update(self.user, self.task_order, **self.task_order_form_data)
- OFFICER_INVITATIONS = [
- {
- "field": "ko_invite",
- "prefix": "ko",
- "role": "contracting_officer",
- "subject": "Review a task order",
- "template": "emails/invitation.txt",
- },
- {
- "field": "cor_invite",
- "prefix": "cor",
- "role": "contracting_officer_representative",
- "subject": "Help with a task order",
- "template": "emails/invitation.txt",
- },
- {
- "field": "so_invite",
- "prefix": "so",
- "role": "security_officer",
- "subject": "Review security for a task order",
- "template": "emails/invitation.txt",
- },
- ]
-
- def _update_officer_invitations(self):
- for officer_type in self.OFFICER_INVITATIONS:
- field = officer_type["field"]
- if (
- hasattr(self.form, field)
- and self.form[field].data
- and not getattr(self.task_order, officer_type["role"])
- ):
- prefix = officer_type["prefix"]
- officer_data = {
- field: getattr(self.task_order, prefix + "_" + field)
- for field in [
- "first_name",
- "last_name",
- "email",
- "phone_number",
- "dod_id",
- ]
- }
- officer = TaskOrders.add_officer(
- self.user, self.task_order, officer_type["role"], officer_data
- )
- pf_officer_member = PortfolioRoles.get(self.portfolio.id, officer.id)
- invite_service = InvitationService(
- self.user,
- pf_officer_member,
- officer_data["email"],
- subject=officer_type["subject"],
- email_template=officer_type["template"],
- )
- invite_service.invite()
-
- def update(self):
- self._update_task_order()
- self._update_officer_invitations()
return self.task_order
diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py
new file mode 100644
index 00000000..61993928
--- /dev/null
+++ b/atst/routes/task_orders/signing.py
@@ -0,0 +1,74 @@
+from flask import url_for, redirect, render_template, g, request as http_request
+
+import datetime
+
+from . import task_orders_bp
+from atst.domain.authz import Authorization
+from atst.domain.exceptions import NotFoundError
+from atst.domain.task_orders import TaskOrders
+from atst.forms.task_order import SignatureForm
+from atst.utils.flash import formatted_flash as flash
+
+
+def find_unsigned_ko_to(task_order_id):
+ task_order = TaskOrders.get(g.current_user, task_order_id)
+ Authorization.check_is_ko(g.current_user, task_order)
+
+ if task_order.signer_dod_id is not None:
+ raise NotFoundError("task_order")
+
+ return task_order
+
+
+@task_orders_bp.route("/task_orders//digital_signature", methods=["GET"])
+def signature_requested(task_order_id):
+ task_order = find_unsigned_ko_to(task_order_id)
+
+ return render_template(
+ "task_orders/signing/signature_requested.html",
+ task_order_id=task_order.id,
+ form=SignatureForm(),
+ )
+
+
+@task_orders_bp.route(
+ "/task_orders//digital_signature", methods=["POST"]
+)
+def record_signature(task_order_id):
+ task_order = find_unsigned_ko_to(task_order_id)
+
+ form_data = {**http_request.form}
+
+ if "unlimited_level_of_warrant" in form_data and form_data[
+ "unlimited_level_of_warrant"
+ ] == ["y"]:
+ del form_data["level_of_warrant"]
+
+ form = SignatureForm(form_data)
+
+ if form.validate():
+ TaskOrders.update(
+ user=g.current_user,
+ task_order=task_order,
+ signer_dod_id=g.current_user.dod_id,
+ signed_at=datetime.datetime.now(),
+ **form.data,
+ )
+
+ flash("task_order_signed")
+ return redirect(
+ url_for(
+ "portfolios.view_task_order",
+ portfolio_id=task_order.portfolio_id,
+ task_order_id=task_order.id,
+ )
+ )
+ else:
+ return (
+ render_template(
+ "task_orders/signing/signature_requested.html",
+ task_order_id=task_order_id,
+ form=form,
+ ),
+ 400,
+ )
diff --git a/atst/utils/flash.py b/atst/utils/flash.py
index a64693e0..5f2dfafd 100644
--- a/atst/utils/flash.py
+++ b/atst/utils/flash.py
@@ -1,6 +1,13 @@
from flask import flash, render_template_string
MESSAGES = {
+ "task_order_signed": {
+ "title_template": "Task Order Signed",
+ "message_template": """
+ Task order has been signed successfully
+ """,
+ "category": "success",
+ },
"new_portfolio_member": {
"title_template": "Member added successfully",
"message_template": """
@@ -128,6 +135,13 @@ MESSAGES = {
""",
"category": "success",
},
+ "task_order_incomplete": {
+ "title_template": "Task Order Incomplete",
+ "message_template": """
+ You must complete your Task Order form before submitting.
+ """,
+ "category": "error",
+ },
}
diff --git a/atst/utils/json.py b/atst/utils/json.py
index 4ce7bd8d..8e2a3217 100644
--- a/atst/utils/json.py
+++ b/atst/utils/json.py
@@ -1,4 +1,5 @@
from flask.json import JSONEncoder
+from werkzeug.datastructures import FileStorage
from datetime import date
from atst.models.attachment import Attachment
@@ -7,6 +8,8 @@ class CustomJSONEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Attachment):
return obj.filename
- if isinstance(obj, date):
+ elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
+ elif isinstance(obj, FileStorage):
+ return obj.filename
return JSONEncoder.default(self, obj)
diff --git a/js/components/forms/funding.js b/js/components/forms/funding.js
index 20b25a2c..8e4497a6 100644
--- a/js/components/forms/funding.js
+++ b/js/components/forms/funding.js
@@ -4,6 +4,7 @@ import { conformToMask } from 'vue-text-mask'
import FormMixin from '../../mixins/form'
import textinput from '../text_input'
import optionsinput from '../options_input'
+import uploadinput from '../upload_input'
export default {
name: 'funding',
@@ -13,6 +14,7 @@ export default {
components: {
textinput,
optionsinput,
+ uploadinput,
},
props: {
@@ -32,7 +34,6 @@ export default {
clin_02 = 0,
clin_03 = 0,
clin_04 = 0,
- csp_estimate,
} = this.initialData
return {
@@ -40,7 +41,6 @@ export default {
clin_02,
clin_03,
clin_04,
- showUpload: !csp_estimate || this.uploadErrors.length > 0,
}
},
@@ -63,9 +63,6 @@ export default {
},
methods: {
- showUploadInput: function() {
- this.showUpload = true
- },
updateBudget: function() {
document.querySelector('#to-target').innerText = this.totalBudgetStr
},
diff --git a/js/components/levelofwarrant.js b/js/components/levelofwarrant.js
new file mode 100644
index 00000000..d46e489c
--- /dev/null
+++ b/js/components/levelofwarrant.js
@@ -0,0 +1,27 @@
+import textinput from './text_input'
+import checkboxinput from './checkbox_input'
+import FormMixin from '../mixins/form'
+
+export default {
+ mixins: [FormMixin],
+
+ components: {
+ textinput,
+ checkboxinput,
+ },
+
+ props: {
+ initialData: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+
+ data() {
+ const { unlimited_level_of_warrant = false } = this.initialData
+
+ return {
+ unlimited_level_of_warrant,
+ }
+ },
+}
diff --git a/js/components/members_list.js b/js/components/members_list.js
index a48d2eb5..7c862a31 100644
--- a/js/components/members_list.js
+++ b/js/components/members_list.js
@@ -61,8 +61,14 @@ export default {
props: {
members: Array,
- role_choices: Array,
- status_choices: Array,
+ role_choices: {
+ type: Array,
+ default: () => [],
+ },
+ status_choices: {
+ type: Array,
+ default: () => [],
+ },
},
data: function() {
@@ -87,7 +93,7 @@ export default {
displayName: 'Environments',
attr: 'num_env',
sortFunc: numericSort,
- class: 'table-cell--align-right',
+ class: 'table-cell--align-center',
},
{
displayName: 'Status',
diff --git a/js/components/sidenav_toggler.js b/js/components/sidenav_toggler.js
new file mode 100644
index 00000000..faba4c3b
--- /dev/null
+++ b/js/components/sidenav_toggler.js
@@ -0,0 +1,30 @@
+import ToggleMixin from '../mixins/toggle'
+
+const cookieName = 'expandSidenav'
+
+export default {
+ name: 'sidenav-toggler',
+
+ mixins: [ToggleMixin],
+
+ props: {
+ defaultVisible: {
+ type: Boolean,
+ default: function() {
+ if (document.cookie.match(cookieName)) {
+ return !!document.cookie.match(cookieName + ' *= *true')
+ } else {
+ return true
+ }
+ },
+ },
+ },
+
+ methods: {
+ toggle: function(e) {
+ e.preventDefault()
+ this.isVisible = !this.isVisible
+ document.cookie = cookieName + '=' + this.isVisible + '; path=/'
+ },
+ },
+}
diff --git a/js/components/text_input.js b/js/components/text_input.js
index 002884a2..506274a6 100644
--- a/js/components/text_input.js
+++ b/js/components/text_input.js
@@ -84,6 +84,10 @@ export default {
}
},
+ onBlur: function(e) {
+ this._checkIfValid({ value: e.target.value, invalidate: true })
+ },
+
//
_checkIfValid: function({ value, invalidate = false }) {
// Validate the value
diff --git a/js/components/toggler.js b/js/components/toggler.js
index 0cd18dc8..a3e1ece5 100644
--- a/js/components/toggler.js
+++ b/js/components/toggler.js
@@ -1,32 +1,14 @@
+import ToggleMixin from '../mixins/toggle'
+
export default {
name: 'toggler',
+ mixins: [ToggleMixin],
+
props: {
defaultVisible: {
type: Boolean,
default: () => false,
},
},
-
- data: function() {
- return {
- isVisible: this.defaultVisible,
- }
- },
-
- render: function(createElement) {
- return createElement(this.$vnode.data.tag, [
- this.$scopedSlots.default({
- isVisible: this.isVisible,
- toggle: this.toggle,
- }),
- ])
- },
-
- methods: {
- toggle: function(e) {
- e.preventDefault()
- this.isVisible = !this.isVisible
- },
- },
}
diff --git a/js/components/upload_input.js b/js/components/upload_input.js
new file mode 100644
index 00000000..a9b31460
--- /dev/null
+++ b/js/components/upload_input.js
@@ -0,0 +1,41 @@
+import createNumberMask from 'text-mask-addons/dist/createNumberMask'
+import { conformToMask } from 'vue-text-mask'
+
+import FormMixin from '../mixins/form'
+import textinput from './text_input'
+import optionsinput from './options_input'
+
+export default {
+ name: 'uploadinput',
+
+ mixins: [FormMixin],
+
+ components: {
+ textinput,
+ optionsinput,
+ },
+
+ props: {
+ initialData: {
+ type: String,
+ },
+ uploadErrors: {
+ type: Array,
+ default: () => [],
+ },
+ },
+
+ data: function() {
+ const pdf = this.initialData
+
+ return {
+ showUpload: !pdf || this.uploadErrors.length > 0,
+ }
+ },
+
+ methods: {
+ showUploadInput: function() {
+ this.showUpload = true
+ },
+ },
+}
diff --git a/js/index.js b/js/index.js
index f9673744..fbdd5814 100644
--- a/js/index.js
+++ b/js/index.js
@@ -6,6 +6,7 @@ import classes from '../styles/atat.scss'
import Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip'
+import levelofwarrant from './components/levelofwarrant'
import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input'
import textinput from './components/text_input'
@@ -20,6 +21,7 @@ import NewApplication from './components/forms/new_application'
import EditEnvironmentRole from './components/forms/edit_environment_role'
import EditApplicationRoles from './components/forms/edit_application_roles'
import funding from './components/forms/funding'
+import uploadinput from './components/upload_input'
import Modal from './mixins/modal'
import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart'
@@ -32,6 +34,7 @@ import RequestsList from './components/requests_list'
import ConfirmationPopover from './components/confirmation_popover'
import { isNotInVerticalViewport } from './lib/viewport'
import DateSelector from './components/date_selector'
+import SidenavToggler from './components/sidenav_toggler'
Vue.config.productionTip = false
@@ -43,6 +46,7 @@ const app = new Vue({
el: '#app-root',
components: {
toggler,
+ levelofwarrant,
optionsinput,
multicheckboxinput,
textinput,
@@ -64,8 +68,10 @@ const app = new Vue({
RequestsList,
ConfirmationPopover,
funding,
+ uploadinput,
DateSelector,
EditOfficerForm,
+ SidenavToggler,
},
mounted: function() {
diff --git a/js/lib/dollars.js b/js/lib/dollars.js
index 5ef65828..2085a0b5 100644
--- a/js/lib/dollars.js
+++ b/js/lib/dollars.js
@@ -5,6 +5,10 @@ export const formatDollars = (value, cents = true) => {
currency: 'USD',
})
} else if (typeof value === 'string') {
+ if (value === '') {
+ return value
+ }
+
return parseFloat(value).toLocaleString('us-US', {
style: 'currency',
currency: 'USD',
diff --git a/js/mixins/toggle.js b/js/mixins/toggle.js
new file mode 100644
index 00000000..d891eb02
--- /dev/null
+++ b/js/mixins/toggle.js
@@ -0,0 +1,23 @@
+export default {
+ data: function() {
+ return {
+ isVisible: this.defaultVisible,
+ }
+ },
+
+ render: function(createElement) {
+ return createElement(this.$vnode.data.tag, [
+ this.$scopedSlots.default({
+ isVisible: this.isVisible,
+ toggle: this.toggle,
+ }),
+ ])
+ },
+
+ methods: {
+ toggle: function(e) {
+ e.preventDefault()
+ this.isVisible = !this.isVisible
+ },
+ },
+}
diff --git a/static/icons/angle-double-left-solid.svg b/static/icons/angle-double-left-solid.svg
new file mode 100644
index 00000000..95887a19
--- /dev/null
+++ b/static/icons/angle-double-left-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/angle-double-right-solid.svg b/static/icons/angle-double-right-solid.svg
new file mode 100644
index 00000000..998f8595
--- /dev/null
+++ b/static/icons/angle-double-right-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/chart-pie.svg b/static/icons/chart-pie.svg
new file mode 100644
index 00000000..e1b476bd
--- /dev/null
+++ b/static/icons/chart-pie.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/cog.svg b/static/icons/cog.svg
new file mode 100644
index 00000000..fb5bd35a
--- /dev/null
+++ b/static/icons/cog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/envelope.svg b/static/icons/envelope.svg
new file mode 100644
index 00000000..a2557ef2
--- /dev/null
+++ b/static/icons/envelope.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/home.svg b/static/icons/home.svg
new file mode 100644
index 00000000..27ee7ab0
--- /dev/null
+++ b/static/icons/home.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/minus.svg b/static/icons/minus.svg
new file mode 100644
index 00000000..ac83426d
--- /dev/null
+++ b/static/icons/minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/styles/atat.scss b/styles/atat.scss
index f2e91d6e..2d18992e 100644
--- a/styles/atat.scss
+++ b/styles/atat.scss
@@ -12,6 +12,7 @@
@import 'elements/buttons';
@import 'elements/panels';
@import 'elements/block_lists';
+@import 'elements/accordians';
@import 'elements/tables';
@import 'elements/sidenav';
@import 'elements/action_group';
@@ -23,6 +24,7 @@
@import 'elements/menu';
@import 'components/topbar';
+@import 'components/top_message';
@import 'components/global_layout';
@import 'components/global_navigation';
@import 'components/portfolio_layout';
diff --git a/styles/components/_footer.scss b/styles/components/_footer.scss
index f6871dd5..0915d67b 100644
--- a/styles/components/_footer.scss
+++ b/styles/components/_footer.scss
@@ -1,6 +1,6 @@
.app-footer {
background-color: $color-white;
- border-top: 1px solid $color-black;
+ border-top: 1px solid $color-gray-lightest;
display: flex;
flex-direction: row;
justify-content: space-between;
@@ -18,6 +18,7 @@
.app-footer__info__link {
margin: (-$gap * 2) (-$gap);
+ font-weight: normal;
.icon--footer {
@include icon-size(16);
diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss
index a80b4ca5..e8e8825d 100644
--- a/styles/components/_global_layout.scss
+++ b/styles/components/_global_layout.scss
@@ -1,5 +1,5 @@
#app-root {
- background-color: $color-gray-lightest;
+ background-color: $color-offwhite;
display: flex;
flex-direction: column;
justify-content: flex-start;
diff --git a/styles/components/_global_navigation.scss b/styles/components/_global_navigation.scss
index bdc3f47e..7d3bba36 100644
--- a/styles/components/_global_navigation.scss
+++ b/styles/components/_global_navigation.scss
@@ -2,29 +2,16 @@
background-color: $color-white;
.sidenav__link {
- padding-right: $gap;
+ padding-right: $gap * 2;
@include media($large-screen) {
padding-right: $gap * 2;
}
}
- .sidenav__link-label {
- @include hide;
-
- @include media($large-screen) {
- @include unhide;
- padding-left: $gap;
- }
- }
-
&.global-navigation__context--portfolio {
.sidenav__link {
padding-right: $gap;
}
-
- .sidenav__link-label {
- @include hide;
- }
}
}
diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss
index e74481a3..9328c239 100644
--- a/styles/components/_portfolio_layout.scss
+++ b/styles/components/_portfolio_layout.scss
@@ -2,61 +2,289 @@
@include media($large-screen) {
@include grid-row;
}
+
+ margin-left: 2 * $gap;
+
+ .line {
+ box-sizing: border-box;
+ height: 2px;
+ width: 100%;
+ border: 1px solid $color-gray-lightest;
+ }
}
-.portfolio-navigation {
- @include panel-margin;
- margin-bottom: $gap * 4;
+.portfolio-breadcrumbs {
+ margin-bottom: $gap * 2;
+ color: $color-gray-medium;
+ font-size: $h5-font-size;
- ul {
- display: flex;
- flex-direction: column;
- li {
- flex-grow: 1;
+ .icon-link {
+ color: $color-blue;
+ font-weight: normal;
+ }
+
+ .icon--tiny {
+ padding: $gap 0;
+ }
+
+ .icon {
+ @include icon-color($color-blue);
+ }
+
+ .portfolio-breadcrumbs__home {
+ &.icon-link--disabled {
+ color: $color-gray-medium;
+ opacity: 1;
+ .icon {
+ @include icon-color($color-gray-medium);
+ }
}
}
- @include media($medium-screen) {
- margin-bottom: $gap * 5;
+ .portfolio-breadcrumbs__crumb {
+ .icon {
+ @include icon-color($color-gray-medium);
+ }
+
+ .icon-link {
+ color: $color-gray-medium;
+ pointer-events: none;
+ &.icon-link--disabled {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.portfolio-header {
+ flex-direction: column;
+ @include media($small-screen) {
+ flex-direction: row;
}
- @include media($large-screen) {
- width: 20rem;
- margin-right: $gap * 2;
+ margin: 2 * $gap;
- ul {
- display: block;
+ .portfolio-header__name {
+ @include h1;
+ }
+
+ .portfolio-header__budget {
+ font-size: $small-font-size;
+ align-items: center;
+
+ .icon-tooltip {
+ margin-left: -$gap / 2;
+ }
+
+ button {
+ margin: 0;
+ padding: 0;
+ }
+
+ .portfolio-header__budget--dollars {
+ font-size: $h2-font-size;
+ font-weight: bold;
+ }
+ }
+
+ .links {
+ justify-content: center;
+ font-size: $small-font-size;
+
+ .icon-link {
+ &.active {
+ color: $color-gray;
+ .icon {
+ @include icon-color($color-gray);
+ }
+ }
+
+ .icon-link--icon {
+ text-align: center;
+ }
+
+ .icon {
+ @include icon-size(30);
+ }
+ }
+ }
+
+ .column-left {
+ width: 12.5rem;
+ float: left;
+ }
+
+ .column-right {
+ margin-left: -.4rem;
+ }
+
+ .portfolio-header__budget--amount {
+ white-space: nowrap;
+ }
+
+ .portfolio-header__budget--cents {
+ font-size: 2rem;
+ margin-top: .75rem;
+ margin-left: -.7rem;
+ font-weight: bold;
+ }
+
+ .portfolio-funding__header--funded-through {
+ flex-grow: 1;
+ text-align: left;
+ font-weight: bold;
+ }
+
+ .unfunded {
+ color: $color-red;
+ .icon {
+ @include icon-color($color-red);
+ }
+ }
+}
+
+@mixin subheading {
+ color: $color-gray-dark;
+ padding: $gap 0;
+ text-transform: uppercase;
+ opacity: 0.54;
+ font-size: $small-font-size;
+ font-weight: bold;
+ margin-bottom: 3 * $gap;
+}
+
+.portfolio-content {
+ margin: 6 * $gap $gap 0 $gap;
+
+ .member-list {
+ .panel {
+ @include shadow-panel;
+ }
+
+ table {
+ box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
+ thead {
+ th:first-child {
+ padding-left: 3 * $gap;
+ }
+ }
+
+ th {
+ background-color: $color-gray-lightest;
+ padding: $gap 2 * $gap;
+ border-top: none;
+ border-bottom: none;
+ color: $color-gray;
+ }
+
+ td {
+ border-bottom: 1px solid $color-gray-lightest;
+ }
+
+ .add-member-link {
+ text-align: right;
+ }
+ }
+ }
+
+ .application-content {
+ .subheading {
+ @include subheading;
+ }
+
+ .panel {
+ @include shadow-panel;
+ }
+
+ .application-list-item {
+ ul {
+ padding-left: 0;
+ }
+ .block-list__footer {
+ border-bottom: none;
+ }
+ .application-edit__env-list-item {
+ label {
+ color: $color-black;
+ }
+ }
+ }
+ }
+
+ .activity-log {
+ border-top: 3px solid $color-blue;
+
+ .subheading {
+ border-top: 0;
+ border-bottom: 1px solid $color-gray-lightest;
+ padding: 1.6rem 1.6rem;
+ font-weight: $font-bold;
+ }
+ }
+
+}
+
+.portfolio-applications {
+ .portfolio-applications__header {
+
+ .portfolio-applications__header--title {
+ @include subheading;
+ }
+
+ .portfolio-applications__header--actions {
+ color: $color-blue;
+ font-size: $small-font-size;
+ .icon {
+ @include icon-color($color-blue);
+ @include icon-size(14);
+ }
+ }
+ }
+
+ .application-list {
+ .toggle-link {
+ background-color: $color-blue-light;
+ .icon {
+ margin: $gap / 2;
+ }
+ }
+
+ .application-list-item {
+ border-radius: 5px;
+ box-shadow: 0 4px 8px 1px rgba(230,230,230,0.5), -4px 4px 8px 1px rgba(230,230,230,0.5);
+ margin-bottom: 6 * $gap;
+
+ .col {
+ max-width: 95%;
+ }
+
+ .application-list-item__environment__name {
+ }
+
+ .application-list-item__environment__csp_link {
+ font-size: $small-font-size;
+ font-weight: normal;
+ &:hover {
+ background-color: $color-aqua-light;
+ }
+ }
}
}
}
.portfolio-funding {
+ .panel {
+ @include shadow-panel;
+ }
+
+ .subheading {
+ @include subheading;
+ margin-top: 6 * $gap;
+ margin-bottom: 2 * $gap;
+ }
+
.portfolio-funding__header {
- padding: 0;
- margin: 0 $gap;
-
- align-items: center;
-
- .portfolio-funding__header--funded-through {
- padding: 2 * $gap;
- flex-grow: 1;
- text-align: left;
- font-weight: bold;
- }
-
- .funded {
- color: $color-green;
- .icon {
- @include icon-color($color-green);
- }
- }
-
- .unfunded {
- color: $color-red;
- .icon {
- @include icon-color($color-red);
- }
- }
+ flex-direction: row-reverse;
}
.pending-task-order {
@@ -64,6 +292,7 @@
align-items: center;
margin: 0;
+ margin-bottom: 2 * $gap;
padding: 2 * $gap;
dt {
@@ -96,34 +325,39 @@
}
}
- .portfolio-total-balance {
- margin-top: -$gap;
- margin-bottom: 3rem;
+ .total-balance {
+ margin-right: 2 * $gap;
+ text-align: right;
+ }
- .row {
- flex-direction: row-reverse;
- margin: 2 * $gap 0;
- padding-right: 14rem;
-
- .label {
- margin: 0 2 * $gap;
- }
- }
+ .responsive-table-wrapper {
+ margin: 0 (-2 * $gap);
+ padding: 2 * $gap;
+ padding-top: 0;
}
table {
- th{
+ box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
+ thead {
+ th:first-child {
+ padding-left: 3 * $gap;
+ }
+ }
+
+ th {
+ background-color: $color-gray-lightest;
+ padding: $gap 2 * $gap;
+ border-top: none;
+ border-bottom: none;
+ color: $color-gray;
+
.icon {
margin-left: 1rem;
}
+ }
- &.period-of-performance {
- color: $color-blue;
-
- .icon {
- @include icon-color($color-primary)
- }
- }
+ td {
+ border-bottom: 1px solid $color-gray-lightest;
}
td.unused-balance {
@@ -146,7 +380,6 @@
&.funded .to-expiration-alert {
color: $color-blue;
-
.icon {
@include icon-color($color-blue);
}
@@ -169,3 +402,39 @@
}
}
}
+
+.portfolio-reports {
+ .portfolio-reports__header {
+ margin-bottom: 4 * $gap;
+
+ .portfolio-reports__header--title {
+ @include subheading;
+ }
+ }
+
+ .panel {
+ @include shadow-panel;
+ margin-bottom: 4 * $gap;
+ }
+}
+
+.portfolio-admin {
+ .edit-portfolio-name.action-group {
+ margin-top: 2rem;
+ }
+
+ .form-row {
+ margin-bottom: 0;
+
+ .form-col {
+ .usa-input--validation--portfolioName {
+ input {
+ max-width: 30em;
+ }
+ .icon-validation {
+ left: 30em;
+ }
+ }
+ }
+ }
+}
diff --git a/styles/components/_search_bar.scss b/styles/components/_search_bar.scss
index 28b536c5..8c4de002 100644
--- a/styles/components/_search_bar.scss
+++ b/styles/components/_search_bar.scss
@@ -6,6 +6,9 @@
padding: $gap;
flex-wrap: wrap;
+ border-top: none;
+ border-bottom: none;
+
@media (min-width:1000px) {
flex-wrap: nowrap;
}
diff --git a/styles/components/_top_message.scss b/styles/components/_top_message.scss
new file mode 100644
index 00000000..24221f1b
--- /dev/null
+++ b/styles/components/_top_message.scss
@@ -0,0 +1,35 @@
+.top-message {
+ padding: $gap * 2;
+
+ @include panel-margin;
+ @include media($medium-screen) {
+ padding: $gap * 4;
+ }
+
+ border-top-width: 1px;
+ border-bottom-width: 1px;
+ border-top-style: solid;
+ border-bottom-style: solid;
+ border-bottom-color: $color-gray-light;
+
+ .title {
+ padding-top: $gap * 2;
+ padding-bottom: $gap * 2;
+ padding-left: 0;
+ padding-right:0;
+ }
+
+ .list-title {
+ padding-top: $gap * 4;
+ font-weight: bold;
+ }
+
+ .list {
+ padding-left: $gap * 2.5;
+ margin-top: 0.5rem;
+ }
+
+ .list-item {
+ margin-bottom: 0.5rem;
+ }
+}
diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss
index 3586faa9..19335b2a 100644
--- a/styles/core/_variables.scss
+++ b/styles/core/_variables.scss
@@ -43,6 +43,7 @@ $font-bold: 700;
$color-blue: #0071bc;
$color-blue-darker: #205493;
$color-blue-darkest: #112e51;
+$color-blue-light: #e5f1ff;
$color-aqua: #02bfe7;
$color-aqua-dark: #00a6d2;
@@ -57,12 +58,13 @@ $color-red-light: #e59393;
$color-red-lightest: #f9dede;
$color-white: #ffffff;
+$color-offwhite: #fbfbfd;
$color-black: #000000;
$color-black-light: #212121;
$color-gray-dark: #323a45;
$color-gray: #5b616b;
-$color-gray-medium: #757575;
+$color-gray-medium: #9b9b9b;
$color-gray-light: #aeb0b5;
$color-gray-lighter: #d6d7d9;
$color-gray-lightest: #f1f1f1;
@@ -83,7 +85,7 @@ $color-green-lighter: #94bfa2;
$color-green-lightest: #e7f4e4;
$color-cool-blue: #205493;
-$color-cool-blue-light: #4773aa;
+$color-cool-blue-light: #4190e2;
$color-cool-blue-lighter: #8ba6ca;
$color-cool-blue-lightest: #dce4ef;
diff --git a/styles/elements/_accordians.scss b/styles/elements/_accordians.scss
new file mode 100644
index 00000000..61ef3c68
--- /dev/null
+++ b/styles/elements/_accordians.scss
@@ -0,0 +1,123 @@
+.accordian {
+ @include block-list;
+
+ box-shadow: 0 4px 10px 0 rgba(193,193,193,0.5);
+ margin-bottom: 6 * $gap;
+
+ .icon-link {
+ margin: -$gap 0;
+ }
+
+ .icon-link,
+ .label {
+ &:first-child {
+ margin-left: -$gap;
+ }
+
+ &:last-child {
+ margin-right: -$gap;
+ }
+
+ }
+}
+
+.accordian__header {
+ @include block-list-header;
+ border-top: 3px solid $color-blue;
+ border-bottom: none;
+ box-shadow: 0 2px 4px 0 rgba(216,218,222,0.58);
+
+ &.row {
+ background: $color-white;
+ }
+}
+
+.accordian__title {
+ @include block-list__title;
+ color: $color-blue;
+ @include h3;
+
+ &.icon-link {
+ margin: 0;
+ display: block;
+ padding: 0 $gap;
+ text-decoration: none;
+ }
+}
+
+.accordian__description {
+ @include block-list__description;
+ font-style: italic;
+ font-size: $small-font-size;
+ color: $color-gray;
+}
+
+.accordian__actions {
+ margin-top: $gap;
+ display: flex;
+ flex-direction: row;
+
+ .icon-link {
+ font-size: $small-font-size;
+ }
+
+ .counter {
+ background-color: $color-cool-blue-light;
+ color: $color-white;
+ border-radius: 2px;
+ padding: $gap / 2 $gap;
+ margin-left: $gap;
+ }
+
+ .separator {
+ border: 1px solid $color-gray-medium;
+ opacity: 0.75;
+ margin: 0 .5 * $gap;
+ }
+}
+
+.accordian__item {
+ @include block-list-item;
+
+ opacity: 0.75;
+ background-color: $color-blue-light;
+ border-bottom: 1px solid rgba($color-gray-light, 0.5);
+
+ &.accordian__item--selectable {
+ > div {
+ display: flex;
+ flex-direction: row-reverse;
+
+ @include ie-only {
+ width: 100%;
+ }
+
+ > label {
+ @include block-list-selectable-label;
+ }
+ }
+
+ > label {
+ @include block-list-selectable-label;
+ }
+
+ input:checked {
+ + label {
+ color: $color-primary;
+ }
+ }
+
+ @include ie-only {
+ dl {
+ width: 100%;
+ padding-left: $gap*4;
+ }
+ }
+
+ }
+}
+
+.accordian__footer {
+ @include block-list__footer;
+ border-top: 0;
+}
diff --git a/styles/elements/_block_lists.scss b/styles/elements/_block_lists.scss
index bfe1d0e3..23c83bbb 100644
--- a/styles/elements/_block_lists.scss
+++ b/styles/elements/_block_lists.scss
@@ -1,5 +1,7 @@
@mixin block-list {
@include panel-margin;
+ @include shadow-panel
+ padding: 0;
ul, dl {
list-style: none;
@@ -15,6 +17,9 @@
display: flex;
flex-direction: row;
justify-content: space-between;
+ background-color: $color-gray-lightest;
+ padding: $gap 2 * $gap;
+ color: $color-gray;
.icon-tooltip {
margin: -$gap;
@@ -59,7 +64,7 @@
margin: 0;
padding: $gap * 2;
border-top: 0;
- border-bottom: 1px dashed $color-gray-light;
+ border-bottom: 1px solid $color-gray-lightest;
@at-root li#{&} {
&:last-child {
diff --git a/styles/elements/_icon_link.scss b/styles/elements/_icon_link.scss
index 1c13e558..23b694e9 100644
--- a/styles/elements/_icon_link.scss
+++ b/styles/elements/_icon_link.scss
@@ -47,6 +47,7 @@
.icon-link {
@include icon-link;
@include icon-link-color($color-primary);
+ text-decoration: underline;
&.icon-link--vertical {
@include icon-link-vertical;
@@ -67,6 +68,7 @@
&.icon-link--disabled {
opacity: 0.3;
pointer-events: none;
+ text-decoration: none;
}
&.icon-link--left {
diff --git a/styles/elements/_icons.scss b/styles/elements/_icons.scss
index 5703356d..a3c60c74 100644
--- a/styles/elements/_icons.scss
+++ b/styles/elements/_icons.scss
@@ -67,7 +67,24 @@
@include icon-color($color-gray);
}
+ &.icon--blue {
+ @include icon-color($color-blue-darker);
+ }
+
&.icon--medium {
@include icon-size(12);
}
+
+ &.icon--gold {
+ @include icon-color($color-gold-dark);
+ }
+
+ &.icon--circle {
+ svg {
+ border-radius: 100%;
+ border-style: solid;
+ border-width: 1px;
+ padding: 2px;
+ }
+ }
}
diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss
index f72f48ee..296b4a71 100644
--- a/styles/elements/_inputs.scss
+++ b/styles/elements/_inputs.scss
@@ -12,7 +12,7 @@
@mixin input-state($state) {
$border-width: 1px;
- $state-color: $color-gray;
+ $state-color: $color-blue;
@if $state == 'error' {
$border-width: 2px;
@@ -283,6 +283,8 @@
}
}
+ @include input-state('default');
+
&.usa-input--error {
@include input-state('error');
}
diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss
index 7d1b31d4..29b78c44 100644
--- a/styles/elements/_panels.scss
+++ b/styles/elements/_panels.scss
@@ -46,6 +46,13 @@
padding: $gap;
}
+@mixin shadow-panel {
+ padding: $gap / 2 0;
+ box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
+ border-top: none;
+ border-bottom: none;
+}
+
.panel {
@include panel-base;
@include panel-theme-default;
diff --git a/styles/elements/_sidenav.scss b/styles/elements/_sidenav.scss
index dcfcfe10..4de80ef8 100644
--- a/styles/elements/_sidenav.scss
+++ b/styles/elements/_sidenav.scss
@@ -1,160 +1,181 @@
-.sidenav {
- @include hide;
+@mixin sidenav__header {
+ padding: $gap ($gap * 2);
+ font-size: $small-font-size;
+ font-weight: bold;
+}
- @include media($large-screen) {
- @include unhide;
+.sidenav-container {
+ position: relative;
+
+ .global-navigation.sidenav {
+ height: 100%;
+ }
+
+ .sidenav {
+ @include media($large-screen) {
+ margin: 0px;
+ }
width: 25rem;
- margin: 0px;
- }
- box-shadow: 0 6px 18px 0 rgba(48,58,65,0.15);
+ box-shadow: 0 6px 18px 0 rgba(48,58,65,0.15);
- .sidenav__title {
- color: $color-gray-dark;
- padding: $gap ($gap * 2);
- text-transform: uppercase;
- opacity: 0.54;
- font-size: $small-font-size;
- font-weight: bold;
- }
-
- ul {
- &.sidenav__list--padded {
- margin: 4 * $gap 0;
+ .sidenav__title {
+ @include sidenav__header;
+ text-transform: uppercase;
+ width: 50%;
+ color: $color-gray-dark;
+ opacity: 0.54;
}
- list-style: none;
- padding: 0;
+ .sidenav__toggle {
+ @include sidenav__header;
+ float: right;
+ color: $color-blue-darker;
- li {
- margin: 0;
- display: block;
+ .toggle-arrows {
+ vertical-align: middle;
+ }
}
- }
+ ul {
+ &.sidenav__list--padded {
+ margin: 4 * $gap 0;
+ }
- .sidenav__divider--small {
- display: block;
- width: 4 * $gap;
- border: 1px solid #D6D7D9;
- margin-left: 2 * $gap;
- margin-bottom: $gap;
- }
+ list-style: none;
+ padding: 0;
- .sidenav__link {
- display: block;
- padding: $gap ($gap * 2);
- color: $color-black;
- text-decoration: none;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-
- .sidenav__link-icon {
- margin-left: - ($gap * .5);
- }
-
- &.sidenav__link--disabled {
- color: $color-shadow;
- pointer-events: none;
- }
-
- &.sidenav__link--add {
- color: $color-blue;
- font-size: $small-font-size;
- .icon {
- @include icon-color($color-blue);
- @include icon-size(14);
+ li {
+ margin: 0;
+ display: block;
}
}
- &.sidenav__link--active {
- @include h4;
- color: $color-primary;
- background-color: $color-aqua-lightest;
- box-shadow: inset ($gap / 2) 0 0 0 $color-primary;
+ .sidenav__divider--small {
+ display: block;
+ width: 4 * $gap;
+ border: 1px solid #D6D7D9;
+ margin-left: 2 * $gap;
+ margin-bottom: $gap;
+ }
+
+ .sidenav__text {
+ margin: 2 * $gap;
+ color: $color-gray;
+ font-style: italic;
+ }
+
+ .sidenav__link {
+ display: block;
+ padding: $gap ($gap * 2);
+ color: $color-black;
+ text-decoration: underline;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
.sidenav__link-icon {
- @include icon-style-active;
+ margin-left: - ($gap * .5);
}
- position: relative;
- .sidenav__link-active_indicator .icon {
- @include icon-color($color-primary);
- position: absolute;
- right: 0;
+ &.sidenav__link--disabled {
+ color: $color-shadow;
+ pointer-events: none;
+ }
+
+ &.sidenav__link--add {
+ color: $color-blue;
+ font-size: $small-font-size;
+ .icon {
+ @include icon-color($color-blue);
+ @include icon-size(14);
+ }
+
+ }
+
+ &.sidenav__link--active {
+ @include h4;
+ color: $color-primary;
+ background-color: $color-aqua-lightest;
+ box-shadow: inset ($gap / 2) 0 0 0 $color-primary;
+
+ .sidenav__link-icon {
+ @include icon-style-active;
+ }
+
+ position: relative;
+ .sidenav__link-active_indicator .icon {
+ @include icon-color($color-primary);
+ position: absolute;
+ right: 0;
+ }
+
+ + ul {
+ background-color: $color-primary;
+
+ .sidenav__link {
+ color: $color-white;
+ background-color: $color-primary;
+
+ &:hover {
+ background-color: $color-blue-darker;
+ }
+
+ &--active {
+ @include h5;
+ color: $color-white;
+ background-color: $color-primary;
+ box-shadow: none;
+ }
+
+ .icon {
+ @include icon-color($color-white);
+ }
+ }
+ }
}
+ ul {
- background-color: $color-primary;
- .sidenav__link {
- color: $color-white;
- background-color: $color-primary;
-
- &:hover {
- background-color: $color-blue-darker;
- }
-
- &--active {
+ li {
+ .sidenav__link {
@include h5;
- color: $color-white;
- background-color: $color-primary;
- box-shadow: none;
- }
+ padding: $gap * .75;
+ padding-left: 4.5rem;
+ border: 0;
+ font-weight: normal;
- .icon {
- @include icon-color($color-white);
+ .sidenav__link-icon {
+ @include icon-size(12);
+ flex-shrink: 0;
+ margin-right: 1.5rem;
+ margin-left: -3rem
+ }
+
+ .sidenav__link-label {
+ padding-left: 0;
+ }
}
}
}
- }
- + ul {
- // padding-bottom: $gap / 2;
+ &:hover {
+ color: $color-primary;
+ background-color: $color-aqua-lightest;
- li {
- .sidenav__link {
- @include h5;
- padding: $gap * .75;
- padding-left: 4.5rem;
- border: 0;
- font-weight: normal;
-
- .sidenav__link-icon {
- @include icon-size(12);
- flex-shrink: 0;
- margin-right: 1.5rem;
- margin-left: -3rem
- }
-
- .sidenav__link-label {
- padding-left: 0;
- }
+ .sidenav__link-icon {
+ @include icon-style-active;
}
+
}
}
+ }
- &:hover {
- color: $color-primary;
- background-color: $color-aqua-lightest;
+ .sidenav--minimized {
+ @extend .sidenav;
- .sidenav__link-icon {
- @include icon-style-active;
- }
-
- }
- }
-}
-
-.sidenav--minimized {
- @extend .sidenav;
-
- @include unhide;
- margin: 0px;
-
- @include media($large-screen) {
- @include hide;
+ width: auto;
+ margin: 0px;
}
}
diff --git a/styles/elements/_tables.scss b/styles/elements/_tables.scss
index 448e378c..a4a418ce 100644
--- a/styles/elements/_tables.scss
+++ b/styles/elements/_tables.scss
@@ -17,6 +17,10 @@
text-align: right;
}
+ &.table-cell--align-center {
+ text-align: center;
+ }
+
&.table-cell--shrink {
width: 1%;
}
@@ -32,9 +36,20 @@
display: table-cell;
}
}
+ }
- .sorting-direction {
- position: absolute;
+ thead {
+ tr th {
+ .sorting-direction {
+ position: inherit;
+ margin-right: -16px;
+ width: 16px;
+ .icon {
+ height: 14px;
+ width: 16px;
+ margin: 0;
+ }
+ }
}
}
diff --git a/styles/sections/_application_list.scss b/styles/sections/_application_list.scss
index 0294d646..101c249e 100644
--- a/styles/sections/_application_list.scss
+++ b/styles/sections/_application_list.scss
@@ -23,4 +23,8 @@
}
}
}
+
+ header.accordian__header {
+ padding: 1.6rem;
+ }
}
diff --git a/styles/sections/_member_edit.scss b/styles/sections/_member_edit.scss
index 902ece36..83e20f73 100644
--- a/styles/sections/_member_edit.scss
+++ b/styles/sections/_member_edit.scss
@@ -1,3 +1,37 @@
+.member-edit {
+ .panel {
+ @include shadow-panel;
+ margin: $gap;
+ padding: 2 * $gap $gap;
+ }
+
+ .subheading {
+ @include subheading;
+ }
+
+ .manage-access {
+ padding: 2 * $gap;
+
+ .subtitle {
+ font-style: italic;
+ font-size: $small-font-size;
+ color: $color-gray;
+ }
+ }
+
+ .application-list-item {
+ margin: 2 * $gap 3 * $gap;
+
+ .block-list__header {
+ border-top-color: $color-gray-light;
+ }
+ }
+
+ .search-bar {
+ margin: 2 * $gap;
+ }
+}
+
.member-card {
@include grid-row;
padding: $gap*2;
diff --git a/styles/sections/_reports.scss b/styles/sections/_reports.scss
index 8f2d5fd5..b881cbad 100644
--- a/styles/sections/_reports.scss
+++ b/styles/sections/_reports.scss
@@ -7,6 +7,11 @@
.funding-summary-row__col {
+ hr {
+ margin: 2 * $gap 0;
+ border-bottom: 1px solid $color-gray-lightest;
+ }
+
@include media($medium-screen) {
@include grid-pad;
flex-grow: 1;
@@ -36,6 +41,11 @@
max-width: 100%;
}
+ .subheading {
+ @include h4;
+ margin: 0 $gap 2 * $gap 0;
+ -ms-flex-negative: 1;
+ }
// Spending Summary
// ===============================
@@ -53,40 +63,27 @@
}
}
- .spend-summary__heading {
- @include h3;
- margin: 0 $gap 0 0;
- -ms-flex-negative: 1;
- }
-
.spend-summary__budget {
- margin: 0 0 0 $gap;
@include ie-only {
margin: $gap 0 0 0;
}
+ }
- > div {
- text-align: right;
- margin: 0 0 ($gap / 2) 0;
+ dl {
+ text-align: left;
+ margin: 0 0 ($gap / 2) 0;
- @include ie-only {
- text-align: left;
- }
+ @include ie-only {
+ text-align: left;
+ }
- dd, dt {
- display: inline;
- }
-
- dt {
- color: $color-gray;
- margin-right: $gap;
- font-weight: normal;
- }
-
- dd {
- font-weight: bold;
- }
+ dt {
+ text-transform: uppercase;
+ color: $color-gray-light;
+ margin-right: $gap;
+ font-weight: bold;
+ font-size: $small-font-size;
}
}
@@ -97,42 +94,42 @@
}
.spend-summary__spent {
- margin: $gap 0 0 0;
+ margin: 2 * $gap 0;
display: flex;
- flex-direction: row-reverse;
+ flex-direction: column;
justify-content: flex-end;
- dd, dt {
- @include h5;
- }
-
dt {
- font-weight: normal;
- margin-left: $gap;
+ letter-spacing: 0.47px;
}
}
}
-
// Task Order Summary
// ===============================
&.to-summary {
- .to-summary__row {
- .to-summary__heading {
- @include h3;
- margin: 0;
- }
+ .icon-link {
+ font-weight: $font-normal
+ }
- .to-summary__to-number {
- margin: 0;
- dd {
- &::before {
- content: '#';
- color: $color-gray;
- margin-right: $gap;
- }
+ .subheading {
+ margin-bottom: 0;
+ }
+
+ .to-summary__heading {
+ @include h4;
+ margin: 0 $gap 0 0;
+ }
+
+ .to-summary__to-number {
+ margin: 0;
+ dd {
+ &::before {
+ content: '#';
+ color: $color-gray;
+ margin-right: $gap;
}
}
@@ -163,23 +160,26 @@
.to-summary__expiration {
dl {
- margin: ($gap * 2) 0 0 0;
+ text-align: right;
+ margin-top: -2 * $gap;
- > div {
- margin: 0 0 ($gap / 2) 0;
+ dd, dt {
+ display: inline;
+ }
- dd, dt {
- display: block;
- }
+ dt {
+ font-size: $small-font-size;
+ text-transform: uppercase;
+ font-weight: $font-bold;
+ color: $color-gray-light;
+ }
- dt {
- color: $color-gray;
- margin-right: $gap;
- font-weight: normal;
- }
+ dd.ending-soon {
+ font-size: $h2-font-size;
+ white-space: nowrap;
- dd {
- font-weight: bold;
+ .icon {
+ @include icon-size(28);
}
}
}
@@ -203,9 +203,12 @@
.spend-table {
+ box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
+
.spend-table__header {
@include panel-base;
@include panel-theme-default;
+ border-top: none;
border-bottom: 0;
display: flex;
flex-direction: row;
@@ -215,8 +218,8 @@
padding: $gap * 2;
.spend-table__title {
- @include h3;
- margin: 0;
+ @include h4;
+ font-size: $lead-font-size;
flex: 2;
}
@@ -227,6 +230,12 @@
}
table {
+ thead th {
+ text-transform: uppercase;
+ border-bottom: 1px solid $color-gray-lightest;
+ border-top: none;
+ }
+
th, td {
white-space: nowrap;
@@ -234,10 +243,6 @@
margin: 0;
}
- &.current-month {
- background-color: $color-aqua-lightest;
- }
-
&.previous-month {
color: $color-gray;
}
@@ -286,28 +291,53 @@
.spend-table__portfolio {
th, td {
font-weight: bold;
+ border-bottom: 1px solid $color-gray-lightest;
}
}
.spend-table__application {
.spend-table__application__toggler {
- @include icon-link-color($color-black-light, $color-gray-lightest);
+ @include icon-link-color($color-blue, $color-gray-lightest);
margin-left: -$gap;
+ color: $color-blue;
.icon {
@include icon-size(12);
margin-right: $gap;
}
+
+ .open-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 5 * $gap;
+ width: 0;
+ height: 0;
+ border-left: 10px solid transparent;
+ border-right: 10px solid transparent;
+
+ border-bottom: 10px solid $color-blue-light;
+ }
+ }
+
+ th, td {
+ border-bottom: none;
+ }
+
+ th[scope=rowgroup] {
+ position: relative;
}
.spend-table__application__env {
- margin-left: $gap;
+ margin-left: 2 * $gap;
- &:last-child {
- td, th {
- padding-bottom: $gap * 5;
- box-shadow: inset 0 (-$gap * 2.5) 0 $color-gray-lightest;
+ th, td {
+ .icon-link {
+ font-weight: $font-normal;
+ font-size: $base-font-size;
}
+
+ border-bottom: 1px dashed $color-white;
+ background-color: $color-blue-light;
}
}
}
diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss
index f916a0b7..530aa49a 100644
--- a/styles/sections/_task_order.scss
+++ b/styles/sections/_task_order.scss
@@ -2,6 +2,11 @@
text-align: center;
padding: 4rem 6rem;
+ .panel {
+ @include shadow-panel;
+ margin-bottom: 6 * $gap;
+ }
+
.task-order-get-started__list {
ul {
list-style: none;
@@ -49,6 +54,10 @@
top: 2.5rem;
margin-left: -23rem;
}
+
+ .usa-button {
+ margin: 0.5em;
+ }
}
p {
@@ -58,6 +67,9 @@
}
.task-order-summary {
+ .panel {
+ @include shadow-panel;
+ }
.alert .alert__actions {
margin-top: 2 * $gap;
@@ -67,7 +79,7 @@
width: 100%;
}
- .label--pending {
+ .label--pending, .label--started {
background-color: $color-gold;
}
@@ -112,6 +124,11 @@
.task-order-next-steps {
flex-grow: 1;
+
+ .panel {
+ padding-bottom: 0;
+ }
+
@include media($xlarge-screen) {
padding-right: $gap;
}
@@ -135,8 +152,17 @@
width: 100%;
}
+ .alert {
+ margin-top: 3 * $gap;
+ margin-bottom: 0;
+ padding: 2 * $gap;
+
+ .alert__message {
+ font-style: italic;
+ }
+ }
+
.task-order-next-steps__icon {
- width: 8%;
padding: $gap $gap 0 0;
justify-content: center;
.complete {
@@ -147,34 +173,29 @@
}
}
- .task-order-next-steps__text {
- width: 60%;
- }
-
.task-order-next-steps__action {
+ min-width: 10 * $gap;
padding: $gap 0 0 $gap;
- width: 32%;
a.usa-button {
width: 100%;
}
}
- .task-order-next-steps__heading {
-
- h4 {
- @include ie-only {
- width: 100%;
+ .task-order-next-steps__text {
+ display: flex;
+ .task-order-next-steps__heading {
+ display: block;
+ max-width: 100%;
+ flex-shrink: 1;
}
- margin: $gap $gap 0 0;
- }
- }
- .task-order-next-steps__description {
- font-style: italic;
}
}
}
.task-order-sidebar {
+ @include media($xlarge-screen) {
+ padding-left: 3 * $gap;
+ }
min-width: 35rem;
hr {
@@ -193,18 +214,33 @@
}
}
- .task-order-invitation-status {
- .invited {
- color: $color-green;
- @include icon-color($color-green);
- }
- .uninvited {
- color: $color-red;
- @include icon-color($color-red);
+ .task-order-invitations {
+ .task-order-invitations__heading {
+ justify-content: space-between;
}
- .task-order-invitation-status__icon {
- padding: 0 0.5rem;
+ .task-order-invitation-status {
+ margin-bottom: 3 * $gap;
+ .task-order-invitation-status__title {
+ font-weight: $font-bold;
+ }
+
+ .invited {
+ color: $color-green;
+ @include icon-color($color-green);
+ }
+ .uninvited {
+ color: $color-red;
+ @include icon-color($color-red);
+ }
+
+ .task-order-invitation-status__icon {
+ padding: 0 0.5rem;
+ }
+ }
+
+ .task-order-invitation-details {
+ font-style: italic;
}
}
}
@@ -230,14 +266,18 @@
}
.task-order-invite-message {
+ font-weight: $font-bold;
+
&.not-sent {
color: $color-red;
- font-weight: $font-bold;
}
&.sent {
color: $color-green;
- font-weight: $font-bold;
+ }
+
+ &.pending {
+ color: $color-gold-dark;
}
}
@@ -357,6 +397,48 @@
}
.officer__form {
+ padding: 1.5rem;
+ background-color: $color-aqua-lightest;
+ border-left-color: $color-blue;
+ border-left-style: solid;
+ border-left-width: $gap / 2;
+ margin-top: 1.5rem;
+
+ .edit-officer {
+ margin-bottom: $gap * 2;
+
+ h4 {
+ color: $color-gray;
+ margin-top: 0;
+ }
+
+ p {
+ font-size: 1.5rem;
+ }
+ }
+
+ .usa-input__title {
+ font-weight: normal;
+ }
+
+ .form-row {
+ margin-bottom: 0rem;
+ margin-top: 0rem;
+ margin-right: 2rem;
+
+ .usa-input {
+ margin-bottom: 1.5rem;
+ }
+
+ &.officer__form--dodId {
+ margin-top: 1.5rem;
+
+ .usa-input {
+ margin-bottom: 0rem;
+ }
+ }
+ }
+
.officer__form--actions {
display: flex;
flex-direction: row;
diff --git a/templates/components/checkbox_input.html b/templates/components/checkbox_input.html
index 9fe38075..613cf337 100644
--- a/templates/components/checkbox_input.html
+++ b/templates/components/checkbox_input.html
@@ -1,11 +1,17 @@
-{% macro CheckboxInput(field, inline=False, classes="") -%}
+{% macro CheckboxInput(
+ field,
+ label=field.label | striptags,
+ inline=False,
+ classes="") -%}
diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html
new file mode 100644
index 00000000..b6835484
--- /dev/null
+++ b/templates/components/upload_input.html
@@ -0,0 +1,24 @@
+{% macro UploadInput(field, show_label=False) -%}
+
+
+
+
+ {% if show_label %}
+ {{ field.label }}
+ {% endif %}
+ {{ field.description }}
+ {{ field }}
+ {% for error in field.errors %}
+ {{error}}
+ {% endfor %}
+
+
+
+ Uploaded {{ field.data.filename }}
+
+
+
+
+
+
+{%- endmacro %}
diff --git a/templates/fragments/audit_events_log.html b/templates/fragments/audit_events_log.html
index 4c9de82a..3b72f7f7 100644
--- a/templates/fragments/audit_events_log.html
+++ b/templates/fragments/audit_events_log.html
@@ -1,10 +1,7 @@
{% from "components/pagination.html" import Pagination %}
-
-
-
+
+ {{ "portfolios.admin.activity_log_title" | translate }}
{% for event in audit_events %}
-
diff --git a/templates/fragments/edit_application_form.html b/templates/fragments/edit_application_form.html
index 9b9e1bbc..ef5dd455 100644
--- a/templates/fragments/edit_application_form.html
+++ b/templates/fragments/edit_application_form.html
@@ -1,18 +1,8 @@
{% from "components/text_input.html" import TextInput %}
-{% set title_text = ('fragments.edit_application_form.existing_application_title' | translate({ "application_name": application.name })) if application else ('fragments.edit_application_form.new_application_title' | translate) %}
-
{{ form.csrf_token }}
-
-
-
{{ title_text }}
-
-
-
-
- {{ "fragments.edit_application_form.explain" | translate }}
-
- {{ TextInput(form.name) }}
- {{ TextInput(form.description, paragraph=True) }}
-
-
+
+ {{ "fragments.edit_application_form.explain" | translate }}
+
+{{ TextInput(form.name) }}
+{{ TextInput(form.description, paragraph=True) }}
diff --git a/templates/fragments/ko_review_alert.html b/templates/fragments/ko_review_alert.html
deleted file mode 100644
index 473b01bb..00000000
--- a/templates/fragments/ko_review_alert.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
-{{ "fragments.ko_review_alert.make_sure" | translate }}:
-
-- {{ "fragments.ko_review_alert.bullet_1" | translate }}
-- {{ "fragments.ko_review_alert.bullet_2" | translate }}
-- {{ "fragments.ko_review_alert.bullet_3" | translate }}
-
diff --git a/templates/fragments/ko_review_message.html b/templates/fragments/ko_review_message.html
new file mode 100644
index 00000000..3433e799
--- /dev/null
+++ b/templates/fragments/ko_review_message.html
@@ -0,0 +1,7 @@
+{{ "task_orders.ko_review.message" | translate }}
+{{ "fragments.ko_review_message.title" | translate }}:
+
+ - {{ "fragments.ko_review_message.bullet_1" | translate }}
+ - {{ "fragments.ko_review_message.bullet_2" | translate }}
+ - {{ "fragments.ko_review_message.bullet_3" | translate }}
+
diff --git a/templates/fragments/task_order_review/funding.html b/templates/fragments/task_order_review/funding.html
index f1df6364..2ff92dbd 100644
--- a/templates/fragments/task_order_review/funding.html
+++ b/templates/fragments/task_order_review/funding.html
@@ -8,7 +8,7 @@
{% else %}
- {{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}
+ {{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}
{{ Icon('alert', classes='icon--red') }} {{ "task_orders.new.review.not_uploaded"| translate }}
{% endif %}
{% endcall %}
diff --git a/templates/fragments/task_order_review/oversight.html b/templates/fragments/task_order_review/oversight.html
index cc519892..cdeb883e 100644
--- a/templates/fragments/task_order_review/oversight.html
+++ b/templates/fragments/task_order_review/oversight.html
@@ -1,15 +1,17 @@
-{% macro ReviewOfficerInfo(heading, first_name, last_name, email, phone_number, dod_id, officer) %}
+{% macro ReviewOfficerInfo(heading, officer_data, has_officer, invite_pending) %}
- {{ first_name }} {{ last_name }}
- {{ email }}
- {% if phone_number %}
- {{ phone_number | usPhone }}
+ {{ officer_data.first_name }} {{ officer_data.last_name }}
+ {{ officer_data.email }}
+ {% if officer_data.phone_number %}
+ {{ officer_data.phone_number | usPhone }}
{% endif %}
- {{ "task_orders.new.review.dod_id" | translate }} {{ dod_id}}
- {% if officer %}
+ {{ "task_orders.new.review.dod_id" | translate }} {{ officer_data.dod_id}}
+ {% if has_officer %}
{{ Icon('ok', classes='icon--green') }}
{{ "task_orders.new.review.invited"| translate }}
+ {% elif invite_pending %}
+ {{ Icon('alert', classes='icon--gold') }} {{ "task_orders.new.review.pending_to"| translate }}
{% else %}
{{ Icon('alert', classes='icon--red') }} {{ "task_orders.new.review.not_invited"| translate }}
{% endif %}
@@ -17,9 +19,24 @@
{% endmacro %}
- {{ ReviewOfficerInfo("task_orders.new.review.ko", task_order.ko_first_name, task_order.ko_last_name, task_order.ko_email, task_order.ko_phone_number, task_order.ko_dod_id, task_order.contracting_officer) }}
- {{ ReviewOfficerInfo("task_orders.new.review.cor", task_order.cor_first_name, task_order.cor_last_name, task_order.cor_email, task_order.cor_phone_number, task_order.cor_dod_id, task_order.contracting_officer_representative) }}
-
-
- {{ ReviewOfficerInfo("task_orders.new.review.so", task_order.so_first_name, task_order.so_last_name, task_order.so_email, task_order.so_phone_number, task_order.so_dod_id, task_order.security_officer) }}
-
+ {{ ReviewOfficerInfo(
+ "task_orders.new.review.ko",
+ task_order.officer_dictionary("contracting_officer"),
+ task_order.contracting_officer,
+ task_order.ko_invitable
+ ) }}
+ {{ ReviewOfficerInfo(
+ "task_orders.new.review.cor",
+ task_order.officer_dictionary("contracting_officer_representative"),
+ task_order.contracting_officer_representative,
+ task_order.cor_invitable
+ ) }}
+
+
+ {{ ReviewOfficerInfo(
+ "task_orders.new.review.so",
+ task_order.officer_dictionary("security_officer"),
+ task_order.security_officer,
+ task_order.so_invitable
+ ) }}
+
diff --git a/templates/navigation/global_sidenav.html b/templates/navigation/global_sidenav.html
index 00783943..7844f6c2 100644
--- a/templates/navigation/global_sidenav.html
+++ b/templates/navigation/global_sidenav.html
@@ -2,23 +2,39 @@
{% from "components/sidenav_item.html" import SidenavItem %}
-
-
Portfolios
-
- {% for other_portfolio in portfolios|sort(attribute='name') %}
- {{ SidenavItem(other_portfolio.name,
- href=url_for("portfolios.show_portfolio", portfolio_id=other_portfolio.id),
- active=(other_portfolio.id | string) == request.view_args.get('portfolio_id')
- ) }}
- {% endfor %}
-
-
-
- Fund a New Portfolio
- {{ Icon("plus", classes="sidenav__link-icon") }}
-
-
-
-
-
Show >>>
+
diff --git a/templates/navigation/portfolio_navigation.html b/templates/navigation/portfolio_navigation.html
deleted file mode 100644
index 5bfca5c6..00000000
--- a/templates/navigation/portfolio_navigation.html
+++ /dev/null
@@ -1,68 +0,0 @@
-{% from "components/sidenav_item.html" import SidenavItem %}
-
-
diff --git a/templates/portfolios/activity/index.html b/templates/portfolios/activity/index.html
index d3a3acf9..c28b766c 100644
--- a/templates/portfolios/activity/index.html
+++ b/templates/portfolios/activity/index.html
@@ -1,6 +1,8 @@
{% extends "portfolios/base.html" %}
{% from "components/pagination.html" import Pagination %}
+{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.admin" | translate %}
+
{% block portfolio_content %}
{% include "fragments/audit_events_log.html" %}
diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html
new file mode 100644
index 00000000..a39c3194
--- /dev/null
+++ b/templates/portfolios/admin.html
@@ -0,0 +1,33 @@
+{% extends "portfolios/base.html" %}
+
+{% from "components/pagination.html" import Pagination %}
+{% from "components/icon.html" import Icon %}
+{% from "components/text_input.html" import TextInput %}
+
+{% set secondary_breadcrumb = "navigation.portfolio_navigation.portfolio_admin" | translate %}
+
+{% block portfolio_content %}
+
+ {% include "fragments/flash.html" %}
+
+
+
+
+
+
+ {% include "fragments/audit_events_log.html" %}
+
+ {{ Pagination(audit_events, 'portfolios.portfolio_admin', portfolio_id=portfolio.id) }}
+
+{% endblock %}
diff --git a/templates/portfolios/applications/base.html b/templates/portfolios/applications/base.html
new file mode 100644
index 00000000..2b633e70
--- /dev/null
+++ b/templates/portfolios/applications/base.html
@@ -0,0 +1,15 @@
+{% extends "portfolios/base.html" %}
+
+{% block portfolio_header %}
+
+{% endblock %}
+
+{% block portfolio_content %}
+
+ {% block application_content %}{% endblock %}
+
+{% endblock %}
diff --git a/templates/portfolios/applications/edit.html b/templates/portfolios/applications/edit.html
index 87a78626..c648f2f7 100644
--- a/templates/portfolios/applications/edit.html
+++ b/templates/portfolios/applications/edit.html
@@ -1,35 +1,43 @@
-{% extends "portfolios/base.html" %}
+{% extends "portfolios/applications/base.html" %}
{% from "components/text_input.html" import TextInput %}
-{% block portfolio_content %}
+{% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %}
+
+{% block application_content %}
+
+
{{ 'portfolios.applications.settings_heading' | translate }}