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="") -%}
{{ field() }} - {{ field.label }} + {% if field.description %} {{ field.description | safe }} diff --git a/templates/components/text_input.html b/templates/components/text_input.html index 97797983..a669acc3 100644 --- a/templates/components/text_input.html +++ b/templates/components/text_input.html @@ -64,6 +64,7 @@ +
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) -%} + +
+ + +
+
+{%- 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 %} -
-
-

{{ "audit_log.header_title" | translate }}

-
- +
+
{{ "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) %}

    {{ heading | translate }}

    - {{ 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 %} - - -