Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/agreements/orders/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
CURRENCY_WHOLE_DIGITS = 16
CURRENCY_DECIMAL_PLACES = 2
CHOICE_PAIR_SIZE = 2
HOURS_PER_BUSINESS_DAY = 8
BUSINESS_TIME = re.compile(r"^\s*(\d+)\s+business\s+(hour|day)s?\s*$", re.IGNORECASE)

RULE_FIELDS = {
"fixed": {"amount"},
Expand All @@ -45,6 +47,15 @@ class Tier:
includes: str = ""


def business_hours(target: str) -> int | None:
"""Convert a response target such as "2 Business Days" to Coverage Window hours, if it has that form."""
match = BUSINESS_TIME.match(target)
if not match:
return None
count = int(match[1])
return count * HOURS_PER_BUSINESS_DAY if match[2].lower() == "day" else count


@dataclass(frozen=True)
class Service:
"""A service available at every tier or at an explicit subset."""
Expand Down
7 changes: 6 additions & 1 deletion apps/agreements/orders/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,12 @@ def __init__(
cast("forms.MultipleChoiceField", self.fields["services"]).choices = [
(service.key, service.name) for service in agreement.services
]
self.initial["services"] = line.services if line else []
# New packages start with every base service the tier offers; the builder keeps unedited
# services in step with tier changes.
self.services_follow_tier = line is None and not self.is_bound
self.initial["services"] = (
line.services if line else [service.key for service in agreement.services_at(self.initial["tier"])]
)
if staff:
self.fields["special_terms"] = forms.CharField(
required=False,
Expand Down
27 changes: 27 additions & 0 deletions apps/agreements/orders/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from apps.agreements.forms.signing import SignForm
from apps.agreements.models import Order, OrderLine, Program
from apps.agreements.orders import documents
from apps.agreements.orders.catalog import business_hours
from apps.agreements.orders.forms import OrderBuilder
from apps.agreements.orders.pricing import build_quote, money
from apps.agreements.registry import get_kind
Expand All @@ -36,9 +37,11 @@
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse

from apps.agreements.orders.catalog import Agreement
from apps.users.models import User

KIND = "order"
SEVERITIES = (("Critical", "P1"), ("High", "P2"), ("Medium", "P3"), ("Low", "P4"))


def _orders() -> QuerySet[Order]:
Expand Down Expand Up @@ -86,6 +89,29 @@ def _program_or_404(request: HttpRequest, slug: str) -> Program:
return get_object_or_404(Program.visible_to(request.user), slug=slug)


def _response_ladders(agreements: list[Agreement]) -> list[list[dict[str, Any]]]:
"""Scale every response target against the slowest one in the program, so agreements compare too."""
hours = [[[business_hours(target) for target in tier.response_targets] for tier in a.tiers] for a in agreements]
slowest = max((h for agreement in hours for tier in agreement for h in tier if h), default=0)
return [
[
{
"name": name,
"code": code,
"cells": [
{
"target": tier.response_targets[index],
"bar": round(100 * h / slowest, 1) if slowest and (h := tier_hours[index]) else None,
}
for tier, tier_hours in zip(agreement.tiers, agreement_hours, strict=True)
],
}
for index, (name, code) in enumerate(SEVERITIES)
]
for agreement, agreement_hours in zip(agreements, hours, strict=True)
]


@never_cache
@require_GET
def program_list(request: HttpRequest) -> HttpResponse:
Expand Down Expand Up @@ -113,6 +139,7 @@ def program_detail(request: HttpRequest, slug: str) -> HttpResponse:
"program": program,
"catalog": program.catalog,
"agreements": agreements,
"families": list(zip(agreements, _response_ladders(agreements), strict=True)),
"under_review": any(agreement.terms.under_review for agreement in agreements),
"orders": _order_rows(_own_orders(request.user).filter(program=program)[:5]),
"nav": "programs",
Expand Down
80 changes: 71 additions & 9 deletions apps/agreements/static/agreements/package-builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@

/* ---------- Program list and overview ---------- */

.agreement-orders .tier-table { margin: 0.5em 0 1.3125em; }
.agreement-orders .tier-table th,
.agreement-orders .tier-table td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; }
.agreement-orders .tier-table thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; }
.agreement-orders .agreement-summary { border-top: 5px solid #caccce; padding-top: 0.5em; margin-top: 1.75em; }
.agreement-orders .agreement-summary h2 { margin-top: 0.25em; }
.agreement-orders .agreement-services { color: #666; font-size: 0.9em; }
Expand All @@ -26,9 +22,79 @@
.program-overview .po-tagline { margin-bottom: 0.5em; }
.program-overview .po-table-wrap { max-width: 100%; overflow-x: auto; }
.program-overview .po-terms { font-size: 0.9em; color: #666; }
.program-overview .po-list-title { margin: 1em 0 0.15em; font-size: 1.0625em; }
.program-overview .agreement-services { margin-bottom: 0.4em; }
.program-overview .po-list { margin: 0 0 1em; font-size: 0.9375em; line-height: 1.5; }
.program-overview .po-list dt { margin-top: 0.5em; border: 0; font-family: SourceSansProBold, Arial, sans-serif; color: #222; }
.program-overview .po-list dd { margin: 0; padding: 0; border: 0; color: #555; }
.program-overview .po-list-note { margin-left: 0.35em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.9em; color: #2b5b84; }

/* Tier comparison: plan columns, with response targets drawn to one program-wide scale. */
.program-overview .po-plans {
--po-blue: #3776ab;
--po-blue-dark: #2b5b84;
--po-blue-quiet: #a9c4dc;
--po-yellow: #ffd343;
--po-line: #e6e8ea;
--po-col: #f3f8fc;
width: 100%;
margin: 0.75em 0 1.75em;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
.program-overview .po-plans tr { background: none; }
.program-overview .po-plans :is(th, td) { padding: 0.55em 0.9em; background: none; border: 0; text-align: left; vertical-align: middle; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.program-overview .po-plans tbody th { width: 30%; padding-left: 0; font-family: SourceSansProRegular, Arial, sans-serif; color: #444; }
.program-overview .po-plans thead :is(th, td) { border-bottom: 1px solid #caccce; }
.program-overview .po-plans .po-plan { padding-top: 0.85em; padding-bottom: 0.9em; border-top: 3px solid var(--po-line); vertical-align: bottom; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1), border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.program-overview .po-plan-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--po-blue-dark); }
.program-overview .po-plan-fee { display: block; margin-top: 0.1em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.75em; line-height: 1.1; color: #222; }
.program-overview .po-plan-unit { display: block; margin-top: 0.15em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; line-height: 1.3; color: #666; }
.program-overview .po-requests :is(th, td) { padding-block: 0.75em; border-bottom: 1px solid var(--po-line); }
.program-overview .po-requests td { font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: #222; }
.program-overview .po-group th { padding: 1.5em 0 0.4em; font-family: SourceSansProBold, Arial, sans-serif; color: #222; }
.program-overview .po-group-note { margin-left: 0.4em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; color: #666; }
.program-overview .po-severity :is(th, td) { padding-block: 0.6em; border-top: 1px solid var(--po-line); }
.program-overview .po-sev { margin-left: 0.3em; padding: 0.1em 0.4em; border-radius: 3px; background-color: #eef1f4; color: #555; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.75em; vertical-align: 0.1em; }
.program-overview .po-target { display: block; color: #333; font-size: 0.9375em; white-space: nowrap; }
.program-overview .po-bar { display: block; width: 100%; height: 6px; margin-top: 0.4em; border-radius: 3px; overflow: hidden; }
.program-overview .po-bar-track { fill: #eef1f4; }
.program-overview .po-bar-fill { fill: var(--po-blue); transition: fill 180ms cubic-bezier(0.22, 1, 0.36, 1); }

/* Pointing at any cell in a tier lights that tier's whole column and quiets the others. */
.program-overview .po-plans:has(:is(thead th, td):hover) .po-bar-fill { fill: var(--po-blue-quiet); }
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2),
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3),
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4),
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5),
.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) tr > :nth-child(6) { background-color: var(--po-col); }
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2) .po-bar-fill,
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3) .po-bar-fill,
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4) .po-bar-fill,
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5) .po-bar-fill,
.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) tr > :nth-child(6) .po-bar-fill { fill: var(--po-blue-dark); }
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) thead tr > :nth-child(2),
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) thead tr > :nth-child(3),
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) thead tr > :nth-child(4),
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) thead tr > :nth-child(5),
.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) thead tr > :nth-child(6) { border-top-color: var(--po-yellow); }

@media (prefers-reduced-motion: no-preference) {
.program-overview .po-bar-fill { transform-box: fill-box; transform-origin: left center; animation: po-bar-grow 720ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.program-overview .po-severity:nth-child(4) .po-bar-fill { animation-delay: 70ms; }
.program-overview .po-severity:nth-child(5) .po-bar-fill { animation-delay: 140ms; }
.program-overview .po-severity:nth-child(6) .po-bar-fill { animation-delay: 210ms; }
}
@keyframes po-bar-grow { from { transform: scaleX(0); } }

@media (max-width: 40em) {
.program-overview .po-invite { grid-template-columns: minmax(0, 1fr); }
.program-overview .po-plans :is(th, td) { padding-inline: 0.45em; }
.program-overview .po-plans tbody th { width: 34%; padding-left: 0; line-height: 1.25; }
.program-overview .po-plan-fee { font-size: 1.25em; }
.program-overview .po-target { font-size: 0.875em; white-space: normal; line-height: 1.25; }
.program-overview .po-invite-cta { justify-self: start; }
}

Expand All @@ -38,10 +104,6 @@
.agreement-orders .order-orgs { color: #666; font-size: 0.95em; }
.agreement-orders .num { font-variant-numeric: tabular-nums; }

@media (max-width: 800px) {
.agreement-orders .tier-table { display: block; overflow-x: auto; }
}

/* ================================================================
Package builder (order form)
================================================================ */
Expand Down Expand Up @@ -234,7 +296,7 @@
.package-builder .pb-extra-family + .pb-extra-family { margin-top: 1.5em; }
.package-builder .pb-extra-family > h3 { margin: 0 0 0.75em; font-size: 1.125em; font-family: SourceSansProBold, Arial, sans-serif; }
.package-builder #pb-extras-empty { padding: 1.5em 0; color: var(--pb-muted); }
.package-builder .pb-comparison-scroll { max-width: 100%; overflow-x: auto; }
.package-builder .pb-comparison-scroll { position: relative; max-width: 100%; overflow-x: auto; }
.package-builder .pb-comparison { width: 100%; min-width: 54em; table-layout: fixed; margin: 0.75em 0; border-collapse: collapse; font-size: 0.875em; line-height: 1.4; }
.package-builder .pb-comparison-label { width: 38%; }
.package-builder .pb-comparison :is(th, td) { padding: 0.65em; border-bottom: 1px solid var(--pb-line); text-align: left; vertical-align: top; }
Expand Down
5 changes: 5 additions & 0 deletions apps/agreements/static/agreements/package-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
include: el.querySelector(".pb-include-input"),
radios: $$(".pb-tier-input", el),
services: $$(".pb-base-service", el),
servicesFollowTier: "servicesFollowTier" in el.dataset,
extraGroup,
extras: extraGroup ? $$(".pb-extra", extraGroup) : [],
};
Expand Down Expand Up @@ -134,6 +135,8 @@
const allowedTiers = row.dataset.serviceTiers.split(" ").filter(Boolean);
const available = !allowedTiers.length || allowedTiers.includes(serviceTierKey);
const box = serviceBox(row);
// Base services the customer hasn't edited track what the chosen tier offers.
if (tier && family.servicesFollowTier && !box.dataset.edited) box.checked = available;
row.hidden = !available && !box.checked;
box.disabled = !tier || (!available && !box.checked);
const conflict = Boolean(tier) && !available && box.checked;
Expand Down Expand Up @@ -815,6 +818,8 @@
} else if (target.classList.contains("pb-tier-input") && !family.include.checked) {
family.include.checked = true;
announce(`${family.name} added at the ${tierColumn(target).dataset.tierName} tier.`);
} else if (target.closest(".pb-base-service")) {
target.dataset.edited = "true";
}
} else if (target.name !== "discount") {
return;
Expand Down
4 changes: 2 additions & 2 deletions apps/agreements/templates/agreements/orders/_family.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load agreements %}{% with agreement=aform.agreement tier_options=aform.tier_options %}
<section class="pb-family{% if show_errors and aform.errors %} has-error{% endif %}" id="family-{{ slug }}" data-family="{{ slug }}" data-family-name="{{ agreement.short_name }}" data-family-title="{{ agreement.title }}" data-default-tier="{{ agreement.default_tier }}" aria-labelledby="family-{{ slug }}-title">
<section class="pb-family{% if show_errors and aform.errors %} has-error{% endif %}" id="family-{{ slug }}" data-family="{{ slug }}" data-family-name="{{ agreement.short_name }}" data-family-title="{{ agreement.title }}" data-default-tier="{{ agreement.default_tier }}"{% if aform.services_follow_tier %} data-services-follow-tier{% endif %} aria-labelledby="family-{{ slug }}-title">
<header class="pb-family-head">
<div class="pb-family-intro">
<h3 class="pb-family-title" id="family-{{ slug }}-title">{{ agreement.short_name }}</h3>
Expand Down Expand Up @@ -35,7 +35,7 @@ <h3 class="pb-family-title" id="family-{{ slug }}-title">{{ agreement.short_name

<fieldset class="pb-group pb-base-services" id="{{ slug }}-services" aria-describedby="{{ slug }}-services-help{% if show_errors and aform.services.errors %} {{ slug }}-services-errors{% endif %}">
<legend>Base services<span class="screen-reader-text"> for {{ agreement.short_name }}</span></legend>
<p class="pb-help" id="{{ slug }}-services-help">Select only the base services you want. Selecting none is allowed. The Service Tier fee stays the same regardless of your base-service selection.</p>
<p class="pb-help" id="{{ slug }}-services-help">Every base service your tier offers starts selected. Deselect any you don't need; the Service Tier fee stays the same either way.</p>
{% if show_errors and aform.services.errors %}<div id="{{ slug }}-services-errors">{{ aform.services.errors }}</div>{% endif %}
<div class="pb-fields">
{% for service, tiers in agreement.service_rows %}
Expand Down
2 changes: 1 addition & 1 deletion apps/agreements/templates/agreements/orders/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{% block title %}Service orders{% endblock %}
{% block body_attributes %}class="psf agreements agreement-orders default-page"{% endblock %}
{% block head %}{{ block.super }}<link rel="stylesheet" href="{% static 'agreements/package-builder.css' %}?v=8">{% endblock %}
{% block head %}{{ block.super }}<link rel="stylesheet" href="{% static 'agreements/package-builder.css' %}?v=12">{% endblock %}
{% block crumbs %}<li>Service orders</li>{% endblock %}

{% block sidebar %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,4 @@ <h2 id="review-title">Agreements for this package</h2>
</article>
{% endblock %}

{% block extra_js %}{{ builder.parameter_conditions|json_script:"order-parameter-conditions" }}<script src="{% static 'agreements/package-builder.js' %}?v=8" defer></script>{% endblock %}
{% block extra_js %}{{ builder.parameter_conditions|json_script:"order-parameter-conditions" }}<script src="{% static 'agreements/package-builder.js' %}?v=9" defer></script>{% endblock %}
Loading
Loading