diff --git a/frontend_focused/README.md b/frontend_focused/README.md
index 020ff695..1162326f 100644
--- a/frontend_focused/README.md
+++ b/frontend_focused/README.md
@@ -132,3 +132,79 @@ Visit `http://localhost:3000/submissions` to start building.
## Optional Bonus
Authentication, deployment, or extra tooling are not required but welcome if scope allows.
+
+---
+
+## Solution Summary
+
+### Approach
+
+**Backend** — `SubmissionFilterSet` (`backend/submissions/filters/submission.py`) was extended beyond
+the starter `status` filter to support `brokerId` (exact match on FK id), `companySearch` (case
+insensitive match across company legal name, industry, and headquarters city via `Q` objects), and the
+optional extras `createdFrom`/`createdTo` (date range on `created_at`, with `createdTo` treated as
+inclusive of the whole day when only a date is supplied) and `hasDocuments`/`hasNotes` (boolean filters
+against the annotated `document_count`/`note_count` used for the list view). `SubmissionViewSet.get_queryset`
+adds `select_related` for `broker`/`company`/`owner` on both list and detail actions and
+`prefetch_related` for `contacts`/`documents`/`notes` on detail, to avoid N+1 queries. The `BrokerViewSet`
+disables pagination so the frontend dropdown can consume a flat array.
+
+**Frontend** — All three React Query hooks (`useSubmissionsList`, `useSubmissionDetail`,
+`useBrokerOptions`) were enabled and wired to real endpoints. The `/submissions` list page keeps filter
+state in React state, debounces the company search input (400ms), and syncs every filter plus the current
+page to the URL query string via `useSearchParams`/`router.replace`, so views are shareable/bookmarkable
+and survive a refresh. Results render in an MUI table with status/priority chips, document/note counts,
+a latest-note preview, and page-based pagination driven by the API's `count`/page-size. A collapsible
+"More filters" section exposes the optional `createdFrom`/`createdTo` date range and `hasDocuments`/
+`hasNotes` tri-state selects. Loading, error (with retry), and empty states are handled by a shared
+`QueryFeedback` component reused on both the list and detail pages. The `/submissions/[id]` detail page
+renders the summary, a contacts table, a documents list (linking to `fileUrl`), and a notes timeline.
+
+### Tradeoffs & Assumptions
+
+- **Company search** matches legal name, industry, *and* city in one field rather than three separate
+ inputs, favoring a simpler UX over precision; a real product might offer per-field search or a
+ typeahead.
+- **Date filters** use native HTML `` fields instead of pulling in
+ `@mui/x-date-pickers` as a new dependency, keeping the bundle smaller at a small cost to visual
+ polish.
+- **Pagination** is page-number based (matching DRF's default `PageNumberPagination`) rather than
+ cursor-based, which is simpler but can shift results slightly if records are created between page
+ loads.
+- **Broker pagination was disabled** entirely rather than teaching the frontend to page through broker
+ results, since the dataset is small and a dropdown needs the full list anyway.
+- **No authentication** was added (per the optional bonus section) since the challenge scope is a
+ single internal workspace view.
+
+### Stretch Goals Implemented
+
+- Optional filters `createdFrom`, `createdTo`, `hasDocuments`, `hasNotes` (backend + frontend UI).
+- Filter state fully synced to the URL (not just wired to the query function).
+- Debounced company search to avoid firing a request per keystroke.
+- Query-level optimizations (`select_related`/`prefetch_related`) to avoid N+1s on list/detail.
+- Targeted backend tests (`backend/submissions/tests.py`) covering every filter, the detail payload
+ shape, 404 handling, and the unpaginated brokers endpoint.
+- Shared, reusable UI primitives (`StatusChip`, `PriorityChip`, `QueryFeedback`) instead of one-off
+ markup per page.
+
+### How to Run
+
+See [Getting Started](#getting-started) above. In short:
+
+```bash
+# Backend
+cd backend
+python -m venv .venv && .venv\Scripts\activate # or source .venv/bin/activate on macOS/Linux
+pip install -r requirements.txt
+python manage.py migrate
+python manage.py seed_submissions --force
+python manage.py test submissions # optional: run the test suite
+python manage.py runserver 0.0.0.0:8000
+
+# Frontend (separate terminal)
+cd frontend
+npm install
+npm run dev # if Turbopack crashes on your machine, use: npx next dev --webpack
+```
+
+Then visit `http://localhost:3000/submissions`.
diff --git a/frontend_focused/backend/db.sqlite3 b/frontend_focused/backend/db.sqlite3
new file mode 100644
index 00000000..eee32c8c
Binary files /dev/null and b/frontend_focused/backend/db.sqlite3 differ
diff --git a/frontend_focused/backend/server/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/server/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..356337d0
Binary files /dev/null and b/frontend_focused/backend/server/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/server/__pycache__/settings.cpython-313.pyc b/frontend_focused/backend/server/__pycache__/settings.cpython-313.pyc
new file mode 100644
index 00000000..03097b98
Binary files /dev/null and b/frontend_focused/backend/server/__pycache__/settings.cpython-313.pyc differ
diff --git a/frontend_focused/backend/server/__pycache__/urls.cpython-313.pyc b/frontend_focused/backend/server/__pycache__/urls.cpython-313.pyc
new file mode 100644
index 00000000..e2ec9545
Binary files /dev/null and b/frontend_focused/backend/server/__pycache__/urls.cpython-313.pyc differ
diff --git a/frontend_focused/backend/server/__pycache__/wsgi.cpython-313.pyc b/frontend_focused/backend/server/__pycache__/wsgi.cpython-313.pyc
new file mode 100644
index 00000000..514e7140
Binary files /dev/null and b/frontend_focused/backend/server/__pycache__/wsgi.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..dc16789f
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/admin.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/admin.cpython-313.pyc
new file mode 100644
index 00000000..64350e15
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/admin.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/apps.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/apps.cpython-313.pyc
new file mode 100644
index 00000000..2233f258
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/apps.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/models.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/models.cpython-313.pyc
new file mode 100644
index 00000000..80378daa
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/models.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/serializers.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/serializers.cpython-313.pyc
new file mode 100644
index 00000000..58193064
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/serializers.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/tests.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/tests.cpython-313.pyc
new file mode 100644
index 00000000..22ef0129
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/tests.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/__pycache__/views.cpython-313.pyc b/frontend_focused/backend/submissions/__pycache__/views.cpython-313.pyc
new file mode 100644
index 00000000..658584ff
Binary files /dev/null and b/frontend_focused/backend/submissions/__pycache__/views.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/filters/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/submissions/filters/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..d928a759
Binary files /dev/null and b/frontend_focused/backend/submissions/filters/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/filters/__pycache__/submission.cpython-313.pyc b/frontend_focused/backend/submissions/filters/__pycache__/submission.cpython-313.pyc
new file mode 100644
index 00000000..a45d7c65
Binary files /dev/null and b/frontend_focused/backend/submissions/filters/__pycache__/submission.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/filters/submission.py b/frontend_focused/backend/submissions/filters/submission.py
index c1ead3dc..76cfa3bf 100644
--- a/frontend_focused/backend/submissions/filters/submission.py
+++ b/frontend_focused/backend/submissions/filters/submission.py
@@ -1,18 +1,50 @@
+import datetime
+
import django_filters
+from django.db.models import Q
from submissions import models
class SubmissionFilterSet(django_filters.FilterSet):
- """Basic filter set for the submissions list endpoint.
-
- Only the status filter is implemented so the candidate can extend the
- remaining filters (broker, company search, optional extras, etc.).
- """
-
status = django_filters.CharFilter(field_name="status", lookup_expr="iexact")
+ brokerId = django_filters.NumberFilter(field_name="broker_id")
+ companySearch = django_filters.CharFilter(method="filter_company_search")
+ createdFrom = django_filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
+ createdTo = django_filters.DateTimeFilter(method="filter_created_to")
+ hasDocuments = django_filters.BooleanFilter(method="filter_has_documents")
+ hasNotes = django_filters.BooleanFilter(method="filter_has_notes")
class Meta:
model = models.Submission
fields = ["status"]
+ def filter_company_search(self, queryset, name, value):
+ if not value:
+ return queryset
+ return queryset.filter(
+ Q(company__legal_name__icontains=value)
+ | Q(company__industry__icontains=value)
+ | Q(company__headquarters_city__icontains=value)
+ )
+
+ def filter_created_to(self, queryset, name, value):
+ if not value:
+ return queryset
+ if value.time() == datetime.time.min:
+ value = value + datetime.timedelta(days=1) - datetime.timedelta(microseconds=1)
+ return queryset.filter(created_at__lte=value)
+
+ def filter_has_documents(self, queryset, name, value):
+ if value is None:
+ return queryset
+ if value:
+ return queryset.filter(document_count__gt=0)
+ return queryset.filter(document_count=0)
+
+ def filter_has_notes(self, queryset, name, value):
+ if value is None:
+ return queryset
+ if value:
+ return queryset.filter(note_count__gt=0)
+ return queryset.filter(note_count=0)
diff --git a/frontend_focused/backend/submissions/management/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/submissions/management/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..a2c27527
Binary files /dev/null and b/frontend_focused/backend/submissions/management/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/management/commands/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/submissions/management/commands/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..7ea502df
Binary files /dev/null and b/frontend_focused/backend/submissions/management/commands/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/management/commands/__pycache__/seed_submissions.cpython-313.pyc b/frontend_focused/backend/submissions/management/commands/__pycache__/seed_submissions.cpython-313.pyc
new file mode 100644
index 00000000..f192bed6
Binary files /dev/null and b/frontend_focused/backend/submissions/management/commands/__pycache__/seed_submissions.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/migrations/__pycache__/0001_initial.cpython-313.pyc b/frontend_focused/backend/submissions/migrations/__pycache__/0001_initial.cpython-313.pyc
new file mode 100644
index 00000000..c6cad49a
Binary files /dev/null and b/frontend_focused/backend/submissions/migrations/__pycache__/0001_initial.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/migrations/__pycache__/__init__.cpython-313.pyc b/frontend_focused/backend/submissions/migrations/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 00000000..0d777af0
Binary files /dev/null and b/frontend_focused/backend/submissions/migrations/__pycache__/__init__.cpython-313.pyc differ
diff --git a/frontend_focused/backend/submissions/tests.py b/frontend_focused/backend/submissions/tests.py
new file mode 100644
index 00000000..cafbd388
--- /dev/null
+++ b/frontend_focused/backend/submissions/tests.py
@@ -0,0 +1,174 @@
+from datetime import timedelta
+
+from django.urls import reverse
+from django.utils import timezone
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+from submissions import models
+
+
+class SubmissionFixtureMixin:
+ def setUp(self):
+ super().setUp()
+
+ self.broker_a = models.Broker.objects.create(
+ name="Alpha Brokerage", primary_contact_email="alpha@example.com"
+ )
+ self.broker_b = models.Broker.objects.create(
+ name="Beta Brokerage", primary_contact_email="beta@example.com"
+ )
+
+ self.company_acme = models.Company.objects.create(
+ legal_name="Acme Robotics", industry="Manufacturing", headquarters_city="Denver"
+ )
+ self.company_globex = models.Company.objects.create(
+ legal_name="Globex Logistics", industry="Transportation", headquarters_city="Reno"
+ )
+
+ self.owner = models.TeamMember.objects.create(
+ full_name="Jamie Rivera", email="jamie@example.com"
+ )
+
+ now = timezone.now()
+
+ self.submission_new_acme = models.Submission.objects.create(
+ company=self.company_acme,
+ broker=self.broker_a,
+ owner=self.owner,
+ status=models.Submission.Status.NEW,
+ priority=models.Submission.Priority.HIGH,
+ summary="New opportunity with Acme",
+ created_at=now - timedelta(days=1),
+ )
+ self.submission_review_globex = models.Submission.objects.create(
+ company=self.company_globex,
+ broker=self.broker_b,
+ owner=self.owner,
+ status=models.Submission.Status.IN_REVIEW,
+ priority=models.Submission.Priority.MEDIUM,
+ summary="Reviewing Globex logistics contract",
+ created_at=now - timedelta(days=10),
+ )
+ self.submission_closed_acme = models.Submission.objects.create(
+ company=self.company_acme,
+ broker=self.broker_b,
+ owner=self.owner,
+ status=models.Submission.Status.CLOSED,
+ priority=models.Submission.Priority.LOW,
+ summary="Closed deal with Acme",
+ created_at=now - timedelta(days=30),
+ )
+
+ models.Document.objects.create(
+ submission=self.submission_new_acme,
+ title="Acme Proposal",
+ doc_type="Contract",
+ file_url="https://example.com/doc.pdf",
+ )
+ models.Note.objects.create(
+ submission=self.submission_new_acme,
+ author_name="Jamie Rivera",
+ body="Initial call went well.",
+ created_at=now - timedelta(hours=2),
+ )
+ models.Note.objects.create(
+ submission=self.submission_new_acme,
+ author_name="Jamie Rivera",
+ body="Follow-up scheduled for next week.",
+ created_at=now - timedelta(hours=1),
+ )
+ models.Contact.objects.create(
+ submission=self.submission_review_globex,
+ name="Pat Lee",
+ role="VP Operations",
+ email="pat@globex.com",
+ phone="555-0100",
+ )
+
+
+class SubmissionListFilterTests(SubmissionFixtureMixin, APITestCase):
+ def get_ids(self, response):
+ return {item["id"] for item in response.data["results"]}
+
+ def test_list_returns_all_by_default(self):
+ response = self.client.get(reverse("submission-list"))
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["count"], 3)
+
+ def test_filter_by_status(self):
+ response = self.client.get(reverse("submission-list"), {"status": "new"})
+ self.assertEqual(self.get_ids(response), {self.submission_new_acme.id})
+
+ def test_filter_by_broker_id(self):
+ response = self.client.get(reverse("submission-list"), {"brokerId": self.broker_b.id})
+ self.assertEqual(
+ self.get_ids(response),
+ {self.submission_review_globex.id, self.submission_closed_acme.id},
+ )
+
+ def test_filter_by_company_search_matches_name_industry_or_city(self):
+ response = self.client.get(reverse("submission-list"), {"companySearch": "acme"})
+ self.assertEqual(
+ self.get_ids(response), {self.submission_new_acme.id, self.submission_closed_acme.id}
+ )
+
+ response = self.client.get(reverse("submission-list"), {"companySearch": "reno"})
+ self.assertEqual(self.get_ids(response), {self.submission_review_globex.id})
+
+ def test_filter_by_created_from_and_to(self):
+ five_days_ago = (timezone.now() - timedelta(days=5)).date().isoformat()
+ response = self.client.get(reverse("submission-list"), {"createdFrom": five_days_ago})
+ self.assertEqual(self.get_ids(response), {self.submission_new_acme.id})
+
+ response = self.client.get(reverse("submission-list"), {"createdTo": five_days_ago})
+ self.assertEqual(
+ self.get_ids(response),
+ {self.submission_review_globex.id, self.submission_closed_acme.id},
+ )
+
+ def test_filter_has_documents(self):
+ response = self.client.get(reverse("submission-list"), {"hasDocuments": "true"})
+ self.assertEqual(self.get_ids(response), {self.submission_new_acme.id})
+
+ response = self.client.get(reverse("submission-list"), {"hasDocuments": "false"})
+ self.assertEqual(
+ self.get_ids(response),
+ {self.submission_review_globex.id, self.submission_closed_acme.id},
+ )
+
+ def test_filter_has_notes(self):
+ response = self.client.get(reverse("submission-list"), {"hasNotes": "true"})
+ self.assertEqual(self.get_ids(response), {self.submission_new_acme.id})
+
+ def test_list_includes_counts_and_latest_note(self):
+ response = self.client.get(reverse("submission-list"), {"status": "new"})
+ payload = response.data["results"][0]
+ self.assertEqual(payload["document_count"], 1)
+ self.assertEqual(payload["note_count"], 2)
+ self.assertEqual(
+ payload["latest_note"]["body_preview"], "Follow-up scheduled for next week."
+ )
+
+
+class SubmissionDetailTests(SubmissionFixtureMixin, APITestCase):
+ def test_detail_includes_nested_relations(self):
+ response = self.client.get(
+ reverse("submission-detail", args=[self.submission_review_globex.id])
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(response.data["contacts"]), 1)
+ self.assertEqual(response.data["contacts"][0]["name"], "Pat Lee")
+ self.assertEqual(response.data["company"]["legal_name"], "Globex Logistics")
+
+ def test_detail_404_for_missing_submission(self):
+ response = self.client.get(reverse("submission-detail", args=[999999]))
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+
+class BrokerEndpointTests(SubmissionFixtureMixin, APITestCase):
+ def test_brokers_list_is_not_paginated(self):
+ response = self.client.get(reverse("broker-list"))
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertIsInstance(response.data, list)
+ self.assertEqual(len(response.data), 2)
diff --git a/frontend_focused/backend/submissions/views.py b/frontend_focused/backend/submissions/views.py
index 53f6f400..423b0e8b 100644
--- a/frontend_focused/backend/submissions/views.py
+++ b/frontend_focused/backend/submissions/views.py
@@ -14,12 +14,16 @@ def get_queryset(self):
if self.action == "list":
latest_note = models.Note.objects.filter(submission_id=OuterRef("pk")).order_by("-created_at")
- queryset = queryset.annotate(
+ queryset = queryset.select_related("broker", "company", "owner").annotate(
document_count=Count("documents", distinct=True),
note_count=Count("notes", distinct=True),
latest_note_author=Subquery(latest_note.values("author_name")[:1]),
latest_note_body=Subquery(latest_note.values("body")[:1]),
latest_note_created_at=Subquery(latest_note.values("created_at")[:1]),
+ ).order_by("-created_at")
+ elif self.action == "retrieve":
+ queryset = queryset.select_related("broker", "company", "owner").prefetch_related(
+ "contacts", "documents", "notes"
)
return queryset
@@ -33,4 +37,5 @@ def get_serializer_class(self):
class BrokerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.Broker.objects.all()
serializer_class = serializers.BrokerSerializer
+ pagination_class = None
diff --git a/frontend_focused/frontend/app/submissions/[id]/page.tsx b/frontend_focused/frontend/app/submissions/[id]/page.tsx
index 5f95027b..a6efa4ec 100644
--- a/frontend_focused/frontend/app/submissions/[id]/page.tsx
+++ b/frontend_focused/frontend/app/submissions/[id]/page.tsx
@@ -8,51 +8,202 @@ import {
Divider,
Link as MuiLink,
Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
Typography,
} from '@mui/material';
import Link from 'next/link';
import { useParams } from 'next/navigation';
+import { QueryFeedback } from '@/components/QueryFeedback';
+import { PriorityChip, StatusChip } from '@/components/StatusChip';
+import { formatDate } from '@/lib/format';
import { useSubmissionDetail } from '@/lib/hooks/useSubmissions';
+function DetailField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
export default function SubmissionDetailPage() {
const params = useParams<{ id: string }>();
const submissionId = params?.id ?? '';
const detailQuery = useSubmissionDetail(submissionId);
+ const submission = detailQuery.data;
return (
-
+
- Submission detail
+
+ {submission ? submission.company.legalName : 'Submission detail'}
+
- Use this page to present the full submission payload along with contacts, documents,
- and notes.
+ Full submission record with contacts, documents, and notes.
-
- Back to list
+
+ ← Back to list
-
-
-
- API data placeholder
-
-
- The React Query call is disabled until you turn it on. Once you enable it and wire up
- serializers on the backend you can render key facts, contacts, documents, and note
- timelines.
-
-
-