Skip to content
Open
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
76 changes: 76 additions & 0 deletions frontend_focused/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="date">` 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`.
Binary file added frontend_focused/backend/db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
44 changes: 38 additions & 6 deletions frontend_focused/backend/submissions/filters/submission.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
174 changes: 174 additions & 0 deletions frontend_focused/backend/submissions/tests.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 6 additions & 1 deletion frontend_focused/backend/submissions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Loading