diff --git a/docs/screenshots/reorganized-flow/06-document-checklist.png b/docs/screenshots/reorganized-flow/06-document-checklist.png
new file mode 100644
index 0000000..b3ac568
Binary files /dev/null and b/docs/screenshots/reorganized-flow/06-document-checklist.png differ
diff --git a/docs/screenshots/reorganized-flow/07-organize-documents.png b/docs/screenshots/reorganized-flow/07-organize-documents.png
new file mode 100644
index 0000000..bab3fd9
Binary files /dev/null and b/docs/screenshots/reorganized-flow/07-organize-documents.png differ
diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py
index 43e5403..d0e6eab 100644
--- a/efile_app/efile/api/dropdown_views.py
+++ b/efile_app/efile/api/dropdown_views.py
@@ -30,7 +30,11 @@ def prioritize_options(api_data, guessed):
if isinstance(api_data, list):
for opt in api_data:
if isinstance(opt, dict) and "code" in opt and "name" in opt:
- options.append({"value": opt["code"], "text": opt["name"]})
+ # Keep other fields the court sends (e.g. "amountincontroversy" on
+ # filing types) available to callers that need more than value/text,
+ # without every caller having to know the raw Tyler field names.
+ extra = {key: value for key, value in opt.items() if key not in ("code", "name")}
+ options.append({"value": opt["code"], "text": opt["name"], **extra})
options.sort(key=lambda x: x["text"])
if not guessed:
@@ -534,6 +538,48 @@ def get_document_types(request):
except Exception as e:
return DropdownAPIViews.error_response(f"Error: {str(e)}")
+ @staticmethod
+ @require_http_methods(["GET"])
+ def get_name_suffixes(request):
+ """Get the court's accepted name suffixes (Jr., Sr., II, ...).
+
+ A suffix has to exactly match one of these for Tyler to accept the
+ party -- it isn't free text, even though it looks like it could be.
+ """
+ try:
+ court_code = request.GET.get("court")
+ jurisdiction = get_jurisdiction_from_request(request)
+
+ if not jurisdiction:
+ return DropdownAPIViews.error_response("Missing required jurisdiction parameter")
+
+ if not court_code:
+ return DropdownAPIViews.error_response("Missing required court parameter")
+
+ api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/{court_code}/name_suffixes"
+ logger.debug("GET %s", api_url)
+ response = requests.get(api_url, timeout=10)
+
+ if response.status_code == 200:
+ api_data = response.json()
+ suffixes = (
+ [
+ {"value": item["code"], "text": item["name"]}
+ for item in api_data
+ if isinstance(item, dict) and item.get("code") and item.get("name")
+ ]
+ if isinstance(api_data, list)
+ else []
+ )
+ return DropdownAPIViews.success_response(suffixes)
+ else:
+ return DropdownAPIViews.error_response(f"API request failed with status {response.status_code}")
+
+ except (requests.RequestException, requests.Timeout) as api_error:
+ return DropdownAPIViews.error_response(f"API request failed: {str(api_error)}")
+ except Exception as e:
+ return DropdownAPIViews.error_response(f"Error: {str(e)}")
+
@staticmethod
@require_http_methods(["GET"])
def get_optional_services(request):
@@ -685,3 +731,4 @@ def get_party_types(request):
get_document_types = DropdownAPIViews.get_document_types
get_optional_services = DropdownAPIViews.get_optional_services
get_party_types = DropdownAPIViews.get_party_types
+get_name_suffixes = DropdownAPIViews.get_name_suffixes
diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py
index 605b1e1..1fbf375 100644
--- a/efile_app/efile/api/urls.py
+++ b/efile_app/efile/api/urls.py
@@ -20,6 +20,7 @@
get_courts,
get_document_types,
get_filing_types,
+ get_name_suffixes,
get_optional_services,
get_party_types,
)
@@ -47,6 +48,7 @@
path("dropdowns/document-types/", get_document_types, name="document_types"),
path("dropdowns/optional-services/", get_optional_services, name="optional_services"),
path("dropdowns/party-types/", get_party_types, name="party_types"),
+ path("dropdowns/name-suffixes/", get_name_suffixes, name="name_suffixes"),
# Form configuration endpoints
path("form-config/", get_form_config, name="form_config"),
path("case-type-config/", get_case_type_config, name="case_type_config"),
diff --git a/efile_app/efile/migrations/0005_document_checklist_state.py b/efile_app/efile/migrations/0005_document_checklist_state.py
new file mode 100644
index 0000000..4269b05
--- /dev/null
+++ b/efile_app/efile/migrations/0005_document_checklist_state.py
@@ -0,0 +1,13 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("efile", "0004_filingdraft_case_title")]
+
+ operations = [
+ migrations.AddField(
+ model_name="filingdraft",
+ name="document_checklist_acknowledged",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py b/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py
new file mode 100644
index 0000000..d95b53e
--- /dev/null
+++ b/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py
@@ -0,0 +1,13 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("efile", "0005_document_checklist_state")]
+
+ operations = [
+ migrations.AddField(
+ model_name="filingdocument",
+ name="requested_optional_services",
+ field=models.JSONField(default=list, blank=True),
+ ),
+ ]
diff --git a/efile_app/efile/migrations/0007_amount_in_controversy.py b/efile_app/efile/migrations/0007_amount_in_controversy.py
new file mode 100644
index 0000000..5990fa7
--- /dev/null
+++ b/efile_app/efile/migrations/0007_amount_in_controversy.py
@@ -0,0 +1,18 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("efile", "0006_filingdocument_requested_optional_services")]
+
+ operations = [
+ migrations.AddField(
+ model_name="filingdraft",
+ name="amount_in_controversy",
+ field=models.CharField(max_length=50, blank=True),
+ ),
+ migrations.AddField(
+ model_name="filingdocument",
+ name="filing_requires_amount_in_controversy",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py
index 7ffe872..7969168 100644
--- a/efile_app/efile/models.py
+++ b/efile_app/efile/models.py
@@ -85,6 +85,12 @@ class Status(models.TextChoices):
optional_services = models.JSONField(default=list, blank=True)
extracted_guesses = models.JSONField(default=dict, blank=True)
+ document_checklist_acknowledged = models.BooleanField(default=False)
+ # The dollar amount at stake, required by the EFSP when any document's
+ # filing type is flagged "amountincontroversy: Required". Stored as text
+ # (like the fee fields) since it's echoed back to the API rather than
+ # computed on.
+ amount_in_controversy = models.CharField(max_length=50, blank=True)
# Area-of-law questionnaire answers (e.g. divorce children questions). These are
# driven by the per-state/case-type config, not a fixed schema, so they live in a
# structured JSON field rather than a column each. Only config-defined keys are
@@ -144,8 +150,17 @@ class Role(models.TextChoices):
document_type_name = models.CharField(max_length=255, blank=True)
filing_component_code = models.CharField(max_length=100, blank=True)
filing_component_name = models.CharField(max_length=255, blank=True)
+ # The court's own "amountincontroversy" flag for this document's filing
+ # type (from the filing-types codes API) is "Required" for some case
+ # types. Recorded per document, since each can carry a different filing
+ # type; case_questions asks for the dollar amount if any document needs it.
+ filing_requires_amount_in_controversy = models.BooleanField(default=False)
courtesy_copy_email = models.EmailField(blank=True)
+ # Codes selected from the court's optional-services list for this document
+ # (e.g. a certified copy), scoped per document since each can have its own
+ # filing type. See efile.api.dropdown_views.get_optional_services.
+ requested_optional_services = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
diff --git a/efile_app/efile/services/document_uploads.py b/efile_app/efile/services/document_uploads.py
new file mode 100644
index 0000000..17f137e
--- /dev/null
+++ b/efile_app/efile/services/document_uploads.py
@@ -0,0 +1,93 @@
+import logging
+import os
+from tempfile import NamedTemporaryFile
+
+from efile.models import FilingDocument
+from efile.services.drafts import read_upload_data, write_upload_data
+from efile.utils.llms import LlmError, extract_fields_from_file
+from efile.utils.s3_upload_handler import S3UploadHandler
+from efile.views.session_api import llm_fields, llm_hints
+from efile.workflow import WorkflowStepKey
+
+logger = logging.getLogger(__name__)
+
+
+def _analyze_lead(uploaded_file, jurisdiction):
+ temp_path = None
+ try:
+ uploaded_file.seek(0)
+ with NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
+ for chunk in uploaded_file.chunks():
+ temp_file.write(chunk)
+ temp_path = temp_file.name
+ return extract_fields_from_file(
+ temp_path,
+ llm_fields.get(jurisdiction, llm_fields["default"]),
+ llm_hint=llm_hints.get(jurisdiction, llm_hints["default"]),
+ )
+ except LlmError:
+ logger.exception("Document extraction failed")
+ return {}
+ finally:
+ if temp_path:
+ try:
+ os.unlink(temp_path)
+ except OSError:
+ logger.warning("Could not remove extraction temp file %s", temp_path)
+
+
+def _guess_payload(found_fields):
+ return {
+ "court": found_fields.get("court name"),
+ "filing type": found_fields.get("filing type"),
+ "case category": found_fields.get("case category"),
+ "case type": found_fields.get("case type"),
+ "docket number": found_fields.get("docket number") or found_fields.get("docker number"),
+ }
+
+
+def upload_files(draft, uploaded_files, jurisdiction, *, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS):
+ """Upload PDFs and merge them into the draft without discarding existing files."""
+
+ handler = S3UploadHandler()
+ if not handler._ensure_initialized():
+ raise ValueError("Document storage is not configured. Please try again later.")
+
+ current = read_upload_data(draft)
+ files = current.setdefault("files", {})
+ supporting = list(files.get("supporting", []))
+ found_lead = False
+
+ for uploaded_file in uploaded_files:
+ validation = handler.validate_file(uploaded_file, max_size_mb=10, allowed_types=[".pdf"])
+ if not validation["valid"]:
+ raise ValueError(f"{uploaded_file.name}: {validation['error']}")
+
+ is_lead = not files.get("lead") and not found_lead
+ role = FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING
+
+ # Analyze the file while it's still open; boto3 closes the fileobj it's given once uploaded.
+ guesses = _guess_payload(_analyze_lead(uploaded_file, jurisdiction)) if is_lead else None
+
+ uploaded_file.seek(0)
+ result = handler.upload_file(uploaded_file, file_type=role)
+ if not result["success"]:
+ raise ValueError(result.get("error", f"Could not upload {uploaded_file.name}."))
+
+ file_data = {
+ "name": uploaded_file.name,
+ "size": uploaded_file.size,
+ "type": uploaded_file.content_type,
+ "url": handler.get_public_url(result["key"]),
+ "s3_key": result["key"],
+ }
+ if is_lead:
+ files["lead"] = file_data
+ found_lead = True
+ current["guesses"] = guesses
+ else:
+ supporting.append(file_data)
+
+ files["supporting"] = supporting
+ write_upload_data(draft, current, current_step=current_step)
+ return current
diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py
index 421b3e7..e03ffe2 100644
--- a/efile_app/efile/services/drafts.py
+++ b/efile_app/efile/services/drafts.py
@@ -273,6 +273,7 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
_put(data, "selected_payment_account", draft.selected_payment_account_id)
_put(data, "selected_payment_account_name", draft.selected_payment_account_name)
_put(data, "optional_services", list(draft.optional_services or []))
+ _put(data, "amount_in_controversy", draft.amount_in_controversy)
_put(data, "reason_for_name_change", draft.name_change_reason)
_put(data, "reason_for_change", draft.name_change_reason)
@@ -504,6 +505,7 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None:
"selected_payment_account_name": draft.selected_payment_account_name,
"optional_services": draft.optional_services,
"extracted_guesses": draft.extracted_guesses,
+ "document_checklist_acknowledged": draft.document_checklist_acknowledged,
"supplemental_fields": draft.supplemental_fields,
"document_count": FilingDocument.objects.filter(draft=draft).count(),
"party_count": FilingParty.objects.filter(draft=draft).count(),
diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py
index 9e36c6f..818a023 100644
--- a/efile_app/efile/services/efsp_errors.py
+++ b/efile_app/efile/services/efsp_errors.py
@@ -13,10 +13,20 @@
400" and no way to act, while the response says exactly which document is
missing exactly which field. Shared by the fee quote and the submission so both
describe the same rejection the same way.
+
+Some rejections instead arrive as a single free-text sentence (a "Malformed
+Interview" body, or a plain ``error`` string) written for a developer reading
+the proxy's logs, not a filer. ``_KNOWN_MESSAGE_HINTS`` recognizes the ones
+that come up in practice -- lifted from the literal strings the proxy raises,
+in ~/EfileProxyServer (see e.g. ``Ecf4Filer.java`` and
+``FilingInformationDocassembleJacksonDeserializer.java``) -- and appends a
+sentence saying what to actually do about it. Unrecognized messages still pass
+through unchanged rather than being hidden.
"""
import json
import re
+from collections.abc import Callable
# Field names the EFSP uses, in the words the UI uses for them.
_FIELD_LABELS = {
@@ -37,6 +47,66 @@
_MAX_RAW_BODY = 300
+# (pattern, hint builder) pairs checked in order against a free-text EFSP
+# message; the first match wins. Each hint tells the filer what to actually do,
+# not just what went wrong. Patterns are deliberately specific substrings of
+# the proxy's own wording so an unrelated message never matches by accident.
+_KNOWN_MESSAGE_HINTS: list[tuple[re.Pattern, Callable[[re.Match], str]]] = [
+ (
+ re.compile(r"doesn't allow subsequent filing into non-indexed cases", re.IGNORECASE),
+ lambda m: (
+ "Go back to the case details step: choose New case and remove the case number, "
+ "or choose Existing case and look up the case instead."
+ ),
+ ),
+ (
+ re.compile(r"needs docket number, but not present", re.IGNORECASE),
+ lambda m: "Go back to the case details step and provide the court's case number for this existing case.",
+ ),
+ (
+ re.compile(r"Document .*? is too big! Must be max (\d+)", re.IGNORECASE),
+ lambda m: (
+ f"One of your PDFs is over the court's {int(m.group(1)):,}-byte limit. "
+ "Compress it or split it into smaller files, then re-upload."
+ ),
+ ),
+ (
+ re.compile(r"All Documents combined are too big! Must be max\s*(\d+)", re.IGNORECASE),
+ lambda m: (
+ f"Your documents add up to more than the court's {int(m.group(1)):,}-byte combined limit. "
+ "Remove or compress some documents and try again."
+ ),
+ ),
+ (
+ re.compile(r"Need a filing type! FilingTypes are empty", re.IGNORECASE),
+ lambda m: (
+ "This court doesn't offer any filing types for that case category and case type "
+ "together. Go back and double-check the case category, case type, and whether "
+ "this is a new or existing case."
+ ),
+ ),
+ (
+ re.compile(r"Amount in controversy required", re.IGNORECASE),
+ lambda m: (
+ "This case type requires an amount in controversy, which this tool doesn't collect yet. "
+ "Contact the court about filing this case another way."
+ ),
+ ),
+]
+
+
+def _actionable_hint(message: str) -> str | None:
+ for pattern, build_hint in _KNOWN_MESSAGE_HINTS:
+ match = pattern.search(message)
+ if match:
+ return build_hint(match)
+ return None
+
+
+def _with_hint(message: str) -> str:
+ hint = _actionable_hint(message)
+ return f"{message} {hint}" if hint else message
+
def describe_efsp_error(response) -> str:
"""Describe why the EFSP refused ``response``'s request.
@@ -70,7 +140,15 @@ def describe_efsp_error(response) -> str:
validation_errors = body.get("validation_errors") or body.get("errors")
if validation_errors:
message += f" - Validation errors: {validation_errors}"
- return message
+ return _with_hint(message)
+
+ # "Malformed Interview" errors (e.g. a docket number on a case the court has
+ # no record of) arrive as {"type": ..., "description": ...} instead.
+ description = body.get("description")
+ if description:
+ error_type = str(body.get("type") or "").strip()
+ message = f"{error_type}: {description}" if error_type else str(description)
+ return _with_hint(message)
return f"the court's filing service returned status {response.status_code}"
diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css
index 53d3e88..82c0f9d 100644
--- a/efile_app/efile/static/css/reorganized-flow.css
+++ b/efile_app/efile/static/css/reorganized-flow.css
@@ -361,6 +361,64 @@
margin-top: 0.3rem;
}
+.review-field>span {
+ color: #263c58;
+ display: block;
+ font-weight: 700;
+ margin-bottom: 0.35rem;
+}
+
+.review-field em {
+ color: #778397;
+ font-size: 0.8rem;
+ font-style: normal;
+ font-weight: 500;
+}
+
+.review-field__hint {
+ color: #758196;
+ display: block;
+ margin-top: 0.3rem;
+ min-height: 1.1em;
+}
+
+.review-field__display {
+ align-items: center;
+ background: #f3faf5;
+ border: 1px solid #cde8d5;
+ border-radius: 8px;
+ display: flex;
+ gap: 0.6rem;
+ justify-content: space-between;
+ padding: 0.6rem 0.8rem;
+}
+
+.review-field__found {
+ align-items: center;
+ color: #16703a;
+ display: flex;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.review-field__found i {
+ flex-shrink: 0;
+}
+
+.review-field__found strong {
+ color: #1d2a3d;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.review-field__edit {
+ flex-shrink: 0;
+ font-weight: 700;
+ padding: 0;
+ text-decoration: none;
+}
+
.path-confirmation {
background: #f7f9fc;
border: 1px solid #dfe5ed;
@@ -516,6 +574,232 @@
margin: 0.25rem 0 0;
}
+.checklist-guidance {
+ align-items: flex-start;
+ background: #eef5ff;
+ border-left: 4px solid #3974ba;
+ border-radius: 8px;
+ color: #294565;
+ display: flex;
+ gap: 0.8rem;
+ margin: 1.5rem 0;
+ padding: 1rem;
+}
+
+.checklist-guidance i {
+ color: #3974ba;
+ margin-top: 0.2rem;
+}
+
+.checklist-guidance p {
+ margin: 0.2rem 0 0;
+}
+
+.checklist-files {
+ border: 1px solid #dce3ec;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.checklist-file {
+ align-items: center;
+ display: grid;
+ gap: 0.8rem;
+ grid-template-columns: auto auto 1fr auto;
+ padding: 0.9rem 1rem;
+}
+
+.checklist-file+.checklist-file {
+ border-top: 1px solid #e4e9f0;
+}
+
+.checklist-file__check {
+ align-items: center;
+ background: #e2f5e9;
+ border-radius: 50%;
+ color: #18733d;
+ display: flex;
+ font-size: 0.7rem;
+ height: 24px;
+ justify-content: center;
+ width: 24px;
+}
+
+.checklist-file__icon {
+ color: #b23b3b;
+ font-size: 1.35rem;
+}
+
+.checklist-file small,
+.checklist-confirmation small {
+ color: #6e7b8f;
+ display: block;
+}
+
+.add-missing-toggle {
+ background: none;
+ border: 0;
+ color: #2c5aa0;
+ font-weight: 700;
+ margin: 1rem 0;
+ padding: 0.4rem 0;
+}
+
+.missing-document-state {
+ background: #f7f9fc;
+ border: 1px dashed #bac7d6;
+ border-radius: 10px;
+ margin-bottom: 1.2rem;
+ padding: 1.2rem;
+}
+
+.checklist-confirmation {
+ align-items: flex-start;
+ background: #fffaf0;
+ border: 1px solid #ead8ad;
+ border-radius: 10px;
+ display: flex;
+ gap: 0.8rem;
+ margin-top: 1.5rem;
+ padding: 1rem;
+}
+
+.organize-list {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.main-document-choice {
+ background: #f6f9fd;
+ border: 1px solid #dce3ec;
+ border-radius: 12px;
+ margin-top: 1.5rem;
+ padding: 1rem;
+}
+
+.main-document-choice legend {
+ color: #263c58;
+ float: none;
+ font-size: 1.05rem;
+ font-weight: 750;
+ margin: 0;
+ width: auto;
+}
+
+.main-document-choice>p {
+ color: #68768a;
+ margin: 0.25rem 0 0.8rem;
+}
+
+.main-document-choice small {
+ color: #6d798d;
+ display: block;
+}
+
+.organize-card {
+ border: 1px solid #dce3ec;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.organize-card__header {
+ align-items: center;
+ background: #f6f9fd;
+ display: grid;
+ gap: 0.8rem;
+ grid-template-columns: auto 1fr auto;
+ padding: 0.9rem 1rem;
+}
+
+.organize-card__icon {
+ color: #b23b3b;
+ font-size: 1.4rem;
+}
+
+.organize-card__header strong,
+.organize-card__position {
+ display: block;
+}
+
+.organize-card__position {
+ color: #66758a;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
+.organize-card__fields {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ padding: 1rem;
+}
+
+.reorder-buttons {
+ display: flex;
+ gap: 0.35rem;
+}
+
+.certified-copy-details {
+ border-top: 1px solid #e3e8ef;
+ padding: 0.8rem 1rem 1rem;
+}
+
+.certified-copy-details summary {
+ color: #38567a;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.optional-services-list {
+ display: grid;
+ gap: 0.2rem;
+ margin-top: 0.8rem;
+}
+
+.optional-services-list small {
+ color: #758196;
+}
+
+.optional-service-description {
+ color: #758196;
+ margin: -0.35rem 0 0.35rem 1.6rem;
+}
+
+.optional-services-toggle {
+ font-size: 0.85rem;
+ font-weight: 700;
+ justify-self: start;
+ padding: 0.2rem 0;
+ text-decoration: none;
+}
+
+.compact-choice-field legend {
+ color: #263c58;
+ float: none;
+ font-size: 1rem;
+ font-weight: 700;
+ margin-bottom: 0.35rem;
+ width: auto;
+}
+
+.compact-choice-list {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.compact-choice-list label {
+ align-items: center;
+ background: #f7f9fc;
+ border: 1px solid #dce3ec;
+ border-radius: 8px;
+ display: flex;
+ gap: 0.65rem;
+ padding: 0.6rem 0.75rem;
+}
+
@media (max-width: 700px) {
.review-grid {
grid-template-columns: 1fr;
@@ -541,4 +825,16 @@
.status-pill {
grid-column: 2;
}
+
+ .checklist-file {
+ grid-template-columns: auto auto 1fr;
+ }
+
+ .checklist-file .status-pill {
+ grid-column: 3;
+ }
+
+ .organize-card__fields {
+ grid-template-columns: 1fr;
+ }
}
\ No newline at end of file
diff --git a/efile_app/efile/static/js/document-checklist.js b/efile_app/efile/static/js/document-checklist.js
new file mode 100644
index 0000000..fbe6bc3
--- /dev/null
+++ b/efile_app/efile/static/js/document-checklist.js
@@ -0,0 +1,35 @@
+(function() {
+ const form = document.getElementById("checklist-upload-form");
+ if (!form) return;
+ const state = document.getElementById("checklist-upload-state");
+ const errorBox = document.getElementById("checklist-upload-error");
+
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ state.hidden = false;
+ errorBox.hidden = true;
+ const button = form.querySelector('button[type="submit"]');
+ button.disabled = true;
+ try {
+ const response = await fetch(window.location.href, {
+ method: "POST",
+ body: new FormData(form),
+ headers: {
+ "X-CSRFToken": apiUtils.getCSRFToken()
+ },
+ });
+ if (response.redirected) {
+ window.location.assign(response.url);
+ return;
+ }
+ const result = await response.json();
+ if (!response.ok || !result.success) throw new Error(result.error || "Could not add documents.");
+ window.location.reload();
+ } catch (error) {
+ state.hidden = true;
+ errorBox.textContent = error.message;
+ errorBox.hidden = false;
+ button.disabled = false;
+ }
+ });
+})();
\ No newline at end of file
diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js
new file mode 100644
index 0000000..cad2c81
--- /dev/null
+++ b/efile_app/efile/static/js/extraction-review.js
@@ -0,0 +1,305 @@
+(function() {
+ const contextEl = document.getElementById("extraction-context");
+ const form = document.getElementById("extraction-review-form");
+ if (!contextEl || !form) return;
+
+ const context = JSON.parse(contextEl.textContent);
+ const guesses = context.guesses || {};
+ const errorBox = document.getElementById("extraction-review-error");
+
+ const fields = {
+ court: {
+ select: document.getElementById("court_code"),
+ nameInput: document.getElementById("court_name"),
+ guessKey: "court",
+ savedCode: context.court_code,
+ },
+ case_category: {
+ select: document.getElementById("case_category_code"),
+ nameInput: document.getElementById("case_category_name"),
+ guessKey: "case category",
+ savedCode: context.case_category_code,
+ },
+ case_type: {
+ select: document.getElementById("case_type_code"),
+ nameInput: document.getElementById("case_type_name"),
+ guessKey: "case type",
+ savedCode: context.case_type_code,
+ },
+ filing_type: {
+ select: document.getElementById("filing_type_code"),
+ nameInput: document.getElementById("filing_type_name"),
+ guessKey: "filing type",
+ savedCode: context.filing_type_code,
+ },
+ };
+
+ Object.entries(fields).forEach(([key, field]) => {
+ const root = field.select.closest(".review-field");
+ field.display = root.querySelector(".review-field__display");
+ field.input = root.querySelector(".review-field__input");
+ field.valueEl = root.querySelector(".review-field__value");
+ field.hint = root.querySelector(".review-field__hint");
+ root.querySelector(".review-field__edit").addEventListener("click", () => setMode(key, "edit"));
+ });
+
+ function setMode(key, mode) {
+ const field = fields[key];
+ field.display.hidden = mode !== "found";
+ field.input.hidden = mode !== "edit";
+ }
+
+ function optionValue(item) {
+ return String(item.value ?? item.code ?? item.id ?? "");
+ }
+
+ function optionText(item) {
+ return (item.text || item.name || optionValue(item)).replace(/\s*\(Recommended\)$/, "");
+ }
+
+ async function getJson(url) {
+ const response = await fetch(url, {
+ headers: {
+ "X-CSRFToken": apiUtils.getCSRFToken()
+ }
+ });
+ const result = await response.json();
+ if (!response.ok || !result.success) {
+ throw new Error(result.error || "Could not load choices from the court.");
+ }
+ return result.data || [];
+ }
+
+ const PLACEHOLDERS = {
+ court: "Choose a court",
+ case_category: "Choose a court first",
+ case_type: "Choose a case category first",
+ filing_type: "Choose a case type first",
+ };
+
+ const DOWNSTREAM = {
+ court: ["case_category", "case_type", "filing_type"],
+ case_category: ["case_type", "filing_type"],
+ case_type: ["filing_type"],
+ filing_type: [],
+ };
+
+ function resetField(key, placeholder) {
+ const field = fields[key];
+ field.select.innerHTML = ``;
+ field.select.disabled = true;
+ field.nameInput.value = "";
+ field.hint.textContent = "";
+ setMode(key, "edit");
+ }
+
+ function resetDownstream(key) {
+ DOWNSTREAM[key].forEach((child) => resetField(child, PLACEHOLDERS[child]));
+ }
+
+ function existingCaseWire() {
+ const checked = form.querySelector('input[name="existing_case"]:checked');
+ return checked && checked.value === "existing" ? "yes" : "no";
+ }
+
+ async function populate(key, options, placeholder) {
+ const field = fields[key];
+ field.select.innerHTML = ``;
+ options.forEach((item) => {
+ const opt = new Option(optionText(item), optionValue(item));
+ if (item.recommended || item.selected || item.default) opt.dataset.recommended = "true";
+ field.select.add(opt);
+ });
+ field.select.disabled = options.length === 0;
+
+ const recommended = Array.from(field.select.options).find((o) => o.dataset.recommended);
+ const savedOption = field.savedCode ?
+ Array.from(field.select.options).find((o) => o.value === field.savedCode) :
+ null;
+ const chosen = savedOption || recommended;
+
+ if (chosen) {
+ field.select.value = chosen.value;
+ field.nameInput.value = chosen.textContent;
+ field.valueEl.textContent = chosen.textContent;
+ setMode(key, "found");
+ await ADVANCE[key]();
+ } else {
+ field.hint.textContent = guesses[field.guessKey] ?
+ "We found a hint in your document, but couldn't match it to an exact choice below." :
+ "We couldn't find this in your document.";
+ setMode(key, "edit");
+ }
+ }
+
+ async function loadCourts() {
+ const field = fields.court;
+ field.select.disabled = true;
+ field.select.innerHTML = "";
+ try {
+ const options = await getJson(`/api/dropdowns/courts/?${new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ guessed_court: guesses.court || "",
+ })}`);
+ await populate("court", options, PLACEHOLDERS.court);
+ } catch (error) {
+ resetField("court", PLACEHOLDERS.court);
+ field.select.disabled = false;
+ field.hint.textContent = error.message;
+ }
+ }
+
+ async function loadCaseCategories() {
+ const courtCode = fields.court.select.value;
+ resetDownstream("case_category");
+ if (!courtCode) {
+ resetField("case_category", PLACEHOLDERS.case_category);
+ return;
+ }
+ const field = fields.case_category;
+ field.select.disabled = true;
+ field.select.innerHTML = "";
+ try {
+ const options = await getJson(`/api/dropdowns/case-categories/?${new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: courtCode,
+ guessed_case_category: guesses["case category"] || "",
+ })}`);
+ await populate("case_category", options, "Choose a case category");
+ } catch (error) {
+ resetField("case_category", "Choose a case category");
+ field.select.disabled = false;
+ field.hint.textContent = error.message;
+ }
+ }
+
+ async function loadCaseTypes() {
+ const courtCode = fields.court.select.value;
+ const categoryCode = fields.case_category.select.value;
+ resetDownstream("case_type");
+ if (!courtCode || !categoryCode) {
+ resetField("case_type", PLACEHOLDERS.case_type);
+ return;
+ }
+ const field = fields.case_type;
+ field.select.disabled = true;
+ field.select.innerHTML = "";
+ try {
+ const options = await getJson(`/api/dropdowns/case-types/?${new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: courtCode,
+ parent: categoryCode,
+ guessed_case_type: guesses["case type"] || "",
+ })}`);
+ await populate("case_type", options, "Choose a case type");
+ } catch (error) {
+ resetField("case_type", "Choose a case type");
+ field.select.disabled = false;
+ field.hint.textContent = error.message;
+ }
+ }
+
+ async function loadFilingTypes() {
+ const courtCode = fields.court.select.value;
+ const categoryCode = fields.case_category.select.value;
+ const typeCode = fields.case_type.select.value;
+ resetDownstream("filing_type");
+ if (!courtCode || !categoryCode || !typeCode) {
+ resetField("filing_type", PLACEHOLDERS.filing_type);
+ return;
+ }
+ const field = fields.filing_type;
+ field.select.disabled = true;
+ field.select.innerHTML = "";
+ try {
+ const options = await getJson(`/api/dropdowns/filing-types/?${new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: courtCode,
+ case_category: categoryCode,
+ case_type: typeCode,
+ existing_case: existingCaseWire(),
+ guessed_filing_type: guesses["filing type"] || "",
+ })}`);
+ await populate("filing_type", options, "Choose a filing type");
+ } catch (error) {
+ resetField("filing_type", "Choose a filing type");
+ field.select.disabled = false;
+ field.hint.textContent = error.message;
+ }
+ }
+
+ const ADVANCE = {
+ court: loadCaseCategories,
+ case_category: loadCaseTypes,
+ case_type: loadFilingTypes,
+ filing_type: async () => {},
+ };
+
+ fields.court.select.addEventListener("change", () => {
+ fields.court.nameInput.value = fields.court.select.selectedOptions[0]?.textContent || "";
+ loadCaseCategories();
+ });
+ fields.case_category.select.addEventListener("change", () => {
+ fields.case_category.nameInput.value = fields.case_category.select.selectedOptions[0]?.textContent || "";
+ loadCaseTypes();
+ });
+ fields.case_type.select.addEventListener("change", () => {
+ fields.case_type.nameInput.value = fields.case_type.select.selectedOptions[0]?.textContent || "";
+ loadFilingTypes();
+ });
+ fields.filing_type.select.addEventListener("change", () => {
+ fields.filing_type.nameInput.value = fields.filing_type.select.selectedOptions[0]?.textContent || "";
+ });
+
+ form.querySelectorAll('input[name="existing_case"]').forEach((radio) => {
+ radio.addEventListener("change", () => {
+ if (fields.case_type.select.value) loadFilingTypes();
+ });
+ });
+
+ // Tyler rejects a docket/case number on a new case ("doesn't allow
+ // subsequent filing into non-indexed cases"), so keep the field out of
+ // the way unless the filer is sure they have one.
+ const docketToggleWrap = document.getElementById("docket-number-toggle");
+ const docketCheckbox = document.getElementById("has-docket-number");
+ const docketInput = document.getElementById("docket_number");
+ const docketHint = document.getElementById("docket-number-hint");
+
+ function updateDocketNumberVisibility() {
+ const existingCase = form.querySelector('input[name="existing_case"]:checked')?.value;
+ if (existingCase === "existing") {
+ docketToggleWrap.hidden = true;
+ docketInput.hidden = false;
+ docketHint.hidden = false;
+ return;
+ }
+ docketToggleWrap.hidden = false;
+ const show = docketCheckbox.checked;
+ docketInput.hidden = !show;
+ docketHint.hidden = !show;
+ if (!show) docketInput.value = "";
+ }
+
+ docketCheckbox.addEventListener("change", updateDocketNumberVisibility);
+ form.querySelectorAll('input[name="existing_case"]').forEach((radio) => {
+ radio.addEventListener("change", updateDocketNumberVisibility);
+ });
+ if (docketInput.value.trim()) docketCheckbox.checked = true;
+ updateDocketNumberVisibility();
+
+ form.addEventListener("submit", (event) => {
+ const isNew = form.querySelector('input[name="existing_case"]:checked')?.value === "new";
+ const missing = isNew && (!fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value);
+ if (missing) {
+ event.preventDefault();
+ errorBox.textContent = "Choose a court, case category, and case type from the lists to continue.";
+ errorBox.hidden = false;
+ errorBox.scrollIntoView({
+ behavior: "smooth",
+ block: "center"
+ });
+ }
+ });
+
+ loadCourts();
+})();
\ No newline at end of file
diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js
new file mode 100644
index 0000000..f319ecd
--- /dev/null
+++ b/efile_app/efile/static/js/organize-documents.js
@@ -0,0 +1,376 @@
+(function() {
+ const form = document.getElementById("organize-documents-form");
+ const contextElement = document.getElementById("organize-context");
+ if (!form || !contextElement) return;
+
+ const context = JSON.parse(contextElement.textContent);
+ const list = document.getElementById("organize-list");
+ const errorBox = document.getElementById("organize-error");
+ const cards = () => Array.from(list.querySelectorAll(".organize-card"));
+ let filingTypes = null;
+
+ function optionValue(item) {
+ return String(item.value || item.code || item.id || "");
+ }
+
+ function optionText(item) {
+ return item.text || item.name || item.description || optionValue(item);
+ }
+
+ function setOptions(select, options, savedValue, placeholder) {
+ select.innerHTML = "";
+ if (!options.length) {
+ const option = new Option(placeholder, "");
+ select.add(option);
+ select.disabled = true;
+ return;
+ }
+ select.add(new Option(placeholder, ""));
+ options.forEach((item) => {
+ const option = new Option(optionText(item), optionValue(item));
+ // Some filing types require an amount in controversy; case_questions
+ // asks for it later, but only needs to if this is set on the chosen
+ // filing type for at least one document in the filing.
+ option.dataset.amountInControversyRequired =
+ String(item.amountincontroversy || "").toLowerCase() === "required";
+ select.add(option);
+ });
+ select.value = savedValue || "";
+ select.disabled = false;
+ }
+
+ function setRadioOptions(container, options, savedValue, fieldName, placeholder) {
+ container.innerHTML = "";
+ if (!options.length) {
+ const message = document.createElement("small");
+ message.textContent = placeholder;
+ container.appendChild(message);
+ return;
+ }
+ options.forEach((item, index) => {
+ const label = document.createElement("label");
+ const input = document.createElement("input");
+ input.className = "form-check-input";
+ input.type = "radio";
+ input.name = fieldName;
+ input.value = optionValue(item);
+ input.required = true;
+ input.checked = input.value === savedValue || (!savedValue && options.length === 1 && index === 0);
+ const text = document.createElement("span");
+ text.textContent = optionText(item);
+ label.append(input, text);
+ container.appendChild(label);
+ });
+ }
+
+ async function getJson(url) {
+ const response = await fetch(url, {
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": apiUtils.getCSRFToken(),
+ },
+ });
+ const result = await response.json();
+ if (!response.ok || !result.success) {
+ throw new Error(result.error || "The court's document choices could not be loaded.");
+ }
+ return result.data || [];
+ }
+
+ async function loadFilingTypes() {
+ if (filingTypes) return filingTypes;
+ const params = new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: context.court,
+ case_category: context.case_category,
+ case_type: context.case_type,
+ existing_case: context.existing_case,
+ guessed_filing_type: context.guessed_filing_type || "",
+ });
+ filingTypes = await getJson(`/api/dropdowns/filing-types/?${params}`);
+ return filingTypes;
+ }
+
+ async function loadDependentOptions(card, filingType) {
+ const documentType = card.querySelector(".document-type-options");
+ const component = card.querySelector(".filing-component-options");
+ const documentId = card.dataset.documentId;
+ if (!filingType) {
+ setRadioOptions(documentType, [], "", `document-type-${documentId}`, "Select a filing type first");
+ setRadioOptions(component, [], "", `filing-component-${documentId}`, "Select a filing type first");
+ return;
+ }
+
+ documentType.innerHTML = "Loading choices…";
+ component.innerHTML = "Loading choices…";
+ const documentParams = new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: context.court,
+ parent: filingType,
+ });
+ const componentParams = new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: context.court,
+ filing_type: filingType,
+ });
+ const [documentTypes, components] = await Promise.all([
+ getJson(`/api/dropdowns/document-types/?${documentParams}`),
+ getJson(`/api/get-filing-components/?${componentParams}`),
+ ]);
+ setRadioOptions(
+ documentType,
+ documentTypes,
+ card.dataset.documentType,
+ `document-type-${documentId}`,
+ "No confidentiality choices are available",
+ );
+
+ let savedComponent = card.dataset.filingComponent;
+ if (!savedComponent && components.length) {
+ const preferredWord = card.dataset.role === "lead" ? "lead" : "attachment";
+ const preferred = components.find((item) => optionText(item).toLowerCase().includes(preferredWord));
+ savedComponent = optionValue(preferred || components[0]);
+ }
+ setRadioOptions(
+ component,
+ components,
+ savedComponent,
+ `filing-component-${documentId}`,
+ "No document roles are available",
+ );
+ }
+
+ // Keywords for the optional services filers actually look for. Courts can
+ // list a dozen+ services (interpreter requests, sealed filings, various
+ // process-server fees...); surfacing all of them by default buries the
+ // handful people come here for, so only these -- plus anything marked
+ // required -- show before the "Show more options" toggle.
+ const COMMON_OPTIONAL_SERVICE_KEYWORDS = [
+ "certified",
+ "copy",
+ "copies",
+ "expedit",
+ "priority",
+ "rush",
+ "courtesy",
+ ];
+
+ function isCommonOptionalService(name) {
+ const lower = (name || "").toLowerCase();
+ return COMMON_OPTIONAL_SERVICE_KEYWORDS.some((keyword) => lower.includes(keyword));
+ }
+
+ function buildOptionalServiceLabel(service, saved) {
+ const code = String(service.code ?? service.id ?? "");
+ const label = document.createElement("label");
+ label.className = "form-check mb-2";
+ const input = document.createElement("input");
+ input.type = "checkbox";
+ input.className = "form-check-input optional-service";
+ input.value = code;
+ input.checked = Boolean(service.required) || saved.has(code);
+ input.disabled = Boolean(service.required);
+ const span = document.createElement("span");
+ span.className = "form-check-label";
+ let text = service.name || service.text || code;
+ const fee = parseFloat(service.fee);
+ if (fee > 0) text += ` ($${fee.toFixed(2)})`;
+ span.textContent = text;
+ label.append(input, span);
+ return {
+ code,
+ label
+ };
+ }
+
+ function appendOptionalService(container, service, saved) {
+ const {
+ code,
+ label
+ } = buildOptionalServiceLabel(service, saved);
+ if (!code) return;
+ container.appendChild(label);
+ if (service.description) {
+ const description = document.createElement("small");
+ description.className = "d-block optional-service-description";
+ description.textContent = service.description;
+ container.appendChild(description);
+ }
+ }
+
+ async function loadOptionalServices(card, filingType) {
+ const container = card.querySelector(".optional-services-list");
+ if (!filingType) {
+ container.innerHTML = "Select a filing type first";
+ return;
+ }
+ container.innerHTML = "Loading choices…";
+ const params = new URLSearchParams({
+ jurisdiction: context.jurisdiction,
+ court: context.court,
+ filing_type_id: filingType,
+ });
+ let services;
+ try {
+ services = await getJson(`/api/dropdowns/optional-services/?${params}`);
+ } catch (error) {
+ container.innerHTML = "";
+ return;
+ }
+ services = services.filter((service) => service.code ?? service.id);
+ if (!services.length) {
+ container.innerHTML = "";
+ return;
+ }
+
+ const saved = new Set((card.dataset.optionalServices || "").split(",").filter(Boolean));
+ let primary = services.filter((service) => service.required || isCommonOptionalService(service.name));
+ let rest = services.filter((service) => !primary.includes(service));
+ if (!primary.length) {
+ primary = services.slice(0, 4);
+ rest = services.slice(4);
+ }
+
+ container.innerHTML = "";
+ primary.forEach((service) => appendOptionalService(container, service, saved));
+
+ if (rest.length) {
+ const moreContainer = document.createElement("div");
+ moreContainer.hidden = true;
+ rest.forEach((service) => appendOptionalService(moreContainer, service, saved));
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "btn btn-link optional-services-toggle";
+ const showMoreText = interpolate(ngettext("Show %s more option", "Show %s more options", rest.length), [rest.length]);
+ toggle.textContent = showMoreText;
+ toggle.addEventListener("click", () => {
+ const wasExpanded = !moreContainer.hidden;
+ moreContainer.hidden = wasExpanded;
+ toggle.textContent = wasExpanded ? showMoreText : gettext("Show fewer options");
+ });
+
+ container.append(toggle, moreContainer);
+ }
+ }
+
+ async function initializeCard(card) {
+ const filingType = card.querySelector(".filing-type");
+ setOptions(filingType, await loadFilingTypes(), card.dataset.filingType, "Choose a filing type");
+ filingType.addEventListener("change", async () => {
+ card.dataset.documentType = "";
+ card.dataset.filingComponent = "";
+ try {
+ await Promise.all([
+ loadDependentOptions(card, filingType.value),
+ loadOptionalServices(card, filingType.value),
+ ]);
+ } catch (error) {
+ showError(error.message);
+ }
+ });
+ await Promise.all([
+ loadDependentOptions(card, filingType.value),
+ loadOptionalServices(card, filingType.value),
+ ]);
+
+ const checkbox = card.querySelector(".courtesy-copy-toggle");
+ const emailWrap = card.querySelector(".courtesy-email-wrap");
+ const email = card.querySelector(".courtesy-email");
+ checkbox.addEventListener("change", () => {
+ emailWrap.hidden = !checkbox.checked;
+ email.required = checkbox.checked;
+ if (!checkbox.checked) email.value = "";
+ });
+ email.required = checkbox.checked;
+ }
+
+ function updatePositions() {
+ const supporting = cards().filter((card) => card.dataset.role === "supporting");
+ supporting.forEach((card, index) => {
+ card.querySelector(".organize-card__position").textContent = `Additional document ${index + 1}`;
+ card.querySelector(".move-up").disabled = index === 0;
+ card.querySelector(".move-down").disabled = index === supporting.length - 1;
+ });
+ }
+
+ list.addEventListener("click", (event) => {
+ const button = event.target.closest(".move-up, .move-down");
+ if (!button) return;
+ const card = button.closest(".organize-card");
+ const supporting = cards().filter((item) => item.dataset.role === "supporting");
+ const index = supporting.indexOf(card);
+ if (button.classList.contains("move-up") && index > 0) {
+ list.insertBefore(card, supporting[index - 1]);
+ } else if (button.classList.contains("move-down") && index < supporting.length - 1) {
+ supporting[index + 1].after(card);
+ }
+ updatePositions();
+ });
+
+ function showError(message) {
+ errorBox.textContent = message;
+ errorBox.hidden = false;
+ errorBox.scrollIntoView({
+ behavior: "smooth",
+ block: "center"
+ });
+ }
+
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ errorBox.hidden = true;
+ if (!form.reportValidity()) return;
+ const button = document.getElementById("save-document-details");
+ button.disabled = true;
+ const documents = cards().map((card) => {
+ const filingType = card.querySelector(".filing-type");
+ const documentType = card.querySelector('.document-type-options input:checked');
+ const component = card.querySelector('.filing-component-options input:checked');
+ const courtesyEmail = card.querySelector(".courtesy-copy-toggle").checked ?
+ card.querySelector(".courtesy-email").value : "";
+ const requestedOptionalServices = Array.from(card.querySelectorAll(".optional-service:checked"))
+ .map((input) => input.value);
+ return {
+ id: Number(card.dataset.documentId),
+ name: card.querySelector(".document-name").value,
+ filing_type: filingType.value,
+ filing_type_name: filingType.selectedOptions[0]?.text || "",
+ document_type: documentType?.value || "",
+ document_type_name: documentType?.closest("label")?.innerText.trim() || "",
+ filing_component: component?.value || "",
+ filing_component_name: component?.closest("label")?.innerText.trim() || "",
+ courtesy_copy_email: courtesyEmail,
+ requested_optional_services: requestedOptionalServices,
+ requires_amount_in_controversy: filingType.selectedOptions[0]?.dataset.amountInControversyRequired === "true",
+ };
+ });
+
+ try {
+ const response = await fetch(window.location.href, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": apiUtils.getCSRFToken(),
+ },
+ body: JSON.stringify({
+ documents,
+ main_document_id: Number(form.elements.namedItem("main_document").value),
+ return_to: context.return_to || "",
+ }),
+ });
+ if (response.redirected) {
+ window.location.assign(response.url);
+ return;
+ }
+ const result = await response.json();
+ if (!response.ok || !result.success) throw new Error(result.error || "Could not save document details.");
+ window.location.assign(result.redirect_url);
+ } catch (error) {
+ showError(error.message);
+ button.disabled = false;
+ }
+ });
+
+ Promise.all(cards().map(initializeCard)).then(updatePositions).catch((error) => showError(error.message));
+})();
\ No newline at end of file
diff --git a/efile_app/efile/templates/efile/document_checklist.html b/efile_app/efile/templates/efile/document_checklist.html
new file mode 100644
index 0000000..76c6795
--- /dev/null
+++ b/efile_app/efile/templates/efile/document_checklist.html
@@ -0,0 +1,92 @@
+{% extends "efile/workflow_base.html" %}
+{% load static %}
+{% load i18n %}
+{% block title %}
+ {% translate "Check your documents" %}
+{% endblock title %}
+{% block workflow_content %}
+
+ {% translate "Review the files below. Add anything else you want the court to receive with this filing." %}
+
+ {% translate "Include every completed court form, exhibit, translation, or proposed order that belongs with this filing. This list cannot determine which legal forms your case requires." %}
+
- {% translate "We read your lead document to get a head start. Check these details and correct anything that is wrong." %}
- {% translate "Do you have all your documents?" %}
+ {% translate "Here's what we found" %}
-
+ {% translate "Tell the court what each PDF is and whether it should be public or confidential. You can also rename and reorder additional documents." %} +
+ + +