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. - - -
-              {JSON.stringify({ submissionId, queryKey: detailQuery.queryKey }, null, 2)}
-            
-
-
+ detailQuery.refetch()} + > + {submission && ( + + + + + + + + + + {submission.summary} + + + + + + + + + + + + + + + + + + + + + Contacts ({submission.contacts.length}) + + {submission.contacts.length === 0 ? ( + No contacts on file. + ) : ( + + + + + Name + Role + Email + Phone + + + + {submission.contacts.map((contact) => ( + + {contact.name} + {contact.role} + {contact.email} + {contact.phone || '—'} + + ))} + +
+
+ )} +
+
+ + + + + Documents ({submission.documents.length}) + + {submission.documents.length === 0 ? ( + No documents attached. + ) : ( + + {submission.documents.map((doc) => ( + + + + {doc.title} + + + {doc.docType} · uploaded {formatDate(doc.uploadedAt)} + + + + ))} + + )} + + + + + + + Notes ({submission.notes.length}) + + {submission.notes.length === 0 ? ( + No notes yet. + ) : ( + }> + {submission.notes.map((note) => ( + + {note.authorName} + + {formatDate(note.createdAt)} + + + {note.body} + + + ))} + + )} + + +
+ )} +
); diff --git a/frontend_focused/frontend/app/submissions/page.tsx b/frontend_focused/frontend/app/submissions/page.tsx index 7473705b..c5f667ce 100644 --- a/frontend_focused/frontend/app/submissions/page.tsx +++ b/frontend_focused/frontend/app/submissions/page.tsx @@ -2,17 +2,32 @@ import { Box, + Button, Card, CardContent, + CircularProgress, + Collapse, Container, - Divider, + Link as MuiLink, MenuItem, + Pagination, Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, TextField, Typography, } from '@mui/material'; -import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { QueryFeedback } from '@/components/QueryFeedback'; +import { PriorityChip, StatusChip } from '@/components/StatusChip'; +import { formatDate, formatRelativeDate } from '@/lib/format'; import { useBrokerOptions } from '@/lib/hooks/useBrokerOptions'; import { useSubmissionsList } from '@/lib/hooks/useSubmissions'; import { SubmissionStatus } from '@/lib/types'; @@ -25,23 +40,190 @@ const STATUS_OPTIONS: { label: string; value: SubmissionStatus | '' }[] = [ { label: 'Lost', value: 'lost' }, ]; +const PAGE_SIZE = 10; + +const TRISTATE_OPTIONS: { label: string; value: '' | 'true' | 'false' }[] = [ + { label: 'Any', value: '' }, + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, +]; + +function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} + export default function SubmissionsPage() { - const [status, setStatus] = useState(''); - const [brokerId, setBrokerId] = useState(''); - const [companyQuery, setCompanyQuery] = useState(''); + return ( + + + + + + } + > + + + ); +} + +function SubmissionsPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [status, setStatus] = useState( + () => (searchParams.get('status') as SubmissionStatus) || '', + ); + const [brokerId, setBrokerId] = useState(() => searchParams.get('brokerId') ?? ''); + const [companyQuery, setCompanyQuery] = useState(() => searchParams.get('companySearch') ?? ''); + const [createdFrom, setCreatedFrom] = useState(() => searchParams.get('createdFrom') ?? ''); + const [createdTo, setCreatedTo] = useState(() => searchParams.get('createdTo') ?? ''); + const [hasDocuments, setHasDocuments] = useState<'' | 'true' | 'false'>( + () => (searchParams.get('hasDocuments') as '' | 'true' | 'false') || '', + ); + const [hasNotes, setHasNotes] = useState<'' | 'true' | 'false'>( + () => (searchParams.get('hasNotes') as '' | 'true' | 'false') || '', + ); + const [showMoreFilters, setShowMoreFilters] = useState(() => + Boolean(createdFrom || createdTo || hasDocuments || hasNotes), + ); + const [page, setPage] = useState(() => Number(searchParams.get('page') ?? '1') || 1); + + const debouncedCompanySearch = useDebouncedValue(companyQuery, 400); const filters = useMemo( () => ({ status: status || undefined, brokerId: brokerId || undefined, - companySearch: companyQuery || undefined, + companySearch: debouncedCompanySearch || undefined, + createdFrom: createdFrom || undefined, + createdTo: createdTo || undefined, + hasDocuments: hasDocuments ? hasDocuments === 'true' : undefined, + hasNotes: hasNotes ? hasNotes === 'true' : undefined, + page: page > 1 ? page : undefined, }), - [status, brokerId, companyQuery], + [ + status, + brokerId, + debouncedCompanySearch, + createdFrom, + createdTo, + hasDocuments, + hasNotes, + page, + ], ); const submissionsQuery = useSubmissionsList(filters); const brokerQuery = useBrokerOptions(); + const syncUrl = useCallback( + (next: { + status: string; + brokerId: string; + companySearch: string; + createdFrom: string; + createdTo: string; + hasDocuments: string; + hasNotes: string; + page: number; + }) => { + const params = new URLSearchParams(); + if (next.status) params.set('status', next.status); + if (next.brokerId) params.set('brokerId', next.brokerId); + if (next.companySearch) params.set('companySearch', next.companySearch); + if (next.createdFrom) params.set('createdFrom', next.createdFrom); + if (next.createdTo) params.set('createdTo', next.createdTo); + if (next.hasDocuments) params.set('hasDocuments', next.hasDocuments); + if (next.hasNotes) params.set('hasNotes', next.hasNotes); + if (next.page > 1) params.set('page', String(next.page)); + + const query = params.toString(); + router.replace(query ? `/submissions?${query}` : '/submissions', { scroll: false }); + }, + [router], + ); + + useEffect(() => { + syncUrl({ + status, + brokerId, + companySearch: debouncedCompanySearch, + createdFrom, + createdTo, + hasDocuments, + hasNotes, + page, + }); + }, [ + status, + brokerId, + debouncedCompanySearch, + createdFrom, + createdTo, + hasDocuments, + hasNotes, + page, + syncUrl, + ]); + + const handleStatusChange = (value: SubmissionStatus | '') => { + setStatus(value); + setPage(1); + }; + + const handleBrokerChange = (value: string) => { + setBrokerId(value); + setPage(1); + }; + + const handleCompanyChange = (value: string) => { + setCompanyQuery(value); + setPage(1); + }; + + const handleCreatedFromChange = (value: string) => { + setCreatedFrom(value); + setPage(1); + }; + + const handleCreatedToChange = (value: string) => { + setCreatedTo(value); + setPage(1); + }; + + const handleHasDocumentsChange = (value: '' | 'true' | 'false') => { + setHasDocuments(value); + setPage(1); + }; + + const handleHasNotesChange = (value: '' | 'true' | 'false') => { + setHasNotes(value); + setPage(1); + }; + + const hasAdvancedFilters = Boolean(createdFrom || createdTo || hasDocuments || hasNotes); + + const handleClearAdvancedFilters = () => { + setCreatedFrom(''); + setCreatedTo(''); + setHasDocuments(''); + setHasNotes(''); + setPage(1); + }; + + const totalCount = submissionsQuery.data?.count ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const results = submissionsQuery.data?.results ?? []; + return ( @@ -50,8 +232,8 @@ export default function SubmissionsPage() { Submissions - Filters update the query parameters and drive backend filtering. Hook these inputs to - your API calls when you implement the actual data fetching. + Review incoming broker opportunities. Filters sync to the URL so you can share or + bookmark a view. @@ -62,7 +244,9 @@ export default function SubmissionsPage() { select label="Status" value={status} - onChange={(event) => setStatus(event.target.value as SubmissionStatus | '')} + onChange={(event) => + handleStatusChange(event.target.value as SubmissionStatus | '') + } fullWidth > {STATUS_OPTIONS.map((option) => ( @@ -75,9 +259,10 @@ export default function SubmissionsPage() { select label="Broker" value={brokerId} - onChange={(event) => setBrokerId(event.target.value)} + onChange={(event) => handleBrokerChange(event.target.value)} fullWidth - helperText="Populate options via /api/brokers" + disabled={brokerQuery.isLoading} + helperText={brokerQuery.isError ? 'Could not load brokers' : undefined} > All brokers {brokerQuery.data?.map((broker) => ( @@ -89,28 +274,185 @@ export default function SubmissionsPage() { setCompanyQuery(event.target.value)} + onChange={(event) => handleCompanyChange(event.target.value)} fullWidth - helperText="Send as ?companySearch=..." + placeholder="Search by name, industry, or city" /> + + + + + + + + handleCreatedFromChange(event.target.value)} + fullWidth + slotProps={{ inputLabel: { shrink: true } }} + /> + handleCreatedToChange(event.target.value)} + fullWidth + slotProps={{ inputLabel: { shrink: true } }} + /> + + handleHasDocumentsChange(event.target.value as '' | 'true' | 'false') + } + fullWidth + > + {TRISTATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + handleHasNotesChange(event.target.value as '' | 'true' | 'false') + } + fullWidth + > + {TRISTATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + - Submission list - - Hook `submissionsQuery` to render rows, totals, and pagination states. The query is - disabled by default so no network calls fire until you enable it. - - - -
-                  {JSON.stringify({ filters, queryKey: submissionsQuery.queryKey }, null, 2)}
-                
+ + Submission list + {!submissionsQuery.isLoading && !submissionsQuery.isError && ( + + {totalCount} submission{totalCount === 1 ? '' : 's'} + + )} + + submissionsQuery.refetch()} + > + + + + + Company + Broker + Status + Priority + Owner + Docs + Notes + Latest note + Created + + + + {results.map((submission) => ( + + + + {submission.company.legalName} + + + {submission.company.industry} · {submission.company.headquartersCity} + + + {submission.broker.name} + + + + + + + {submission.owner.fullName} + {submission.documentCount} + {submission.noteCount} + + {submission.latestNote ? ( + <> + + {submission.latestNote.bodyPreview} + + + {submission.latestNote.authorName} ·{' '} + {formatRelativeDate(submission.latestNote.createdAt)} + + + ) : ( + + — + + )} + + {formatDate(submission.createdAt)} + + ))} + +
+
+ + {totalPages > 1 && ( + + setPage(value)} + color="primary" + /> + + )} +
diff --git a/frontend_focused/frontend/components/QueryFeedback.tsx b/frontend_focused/frontend/components/QueryFeedback.tsx new file mode 100644 index 00000000..4e74449f --- /dev/null +++ b/frontend_focused/frontend/components/QueryFeedback.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material'; +import { PropsWithChildren } from 'react'; + +interface QueryFeedbackProps { + isLoading: boolean; + isError: boolean; + errorMessage?: string; + isEmpty?: boolean; + emptyMessage?: string; + onRetry?: () => void; +} + +export function QueryFeedback({ + isLoading, + isError, + errorMessage, + isEmpty = false, + emptyMessage = 'No results found.', + onRetry, + children, +}: PropsWithChildren) { + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + + Retry + + ) : undefined + } + > + {errorMessage ?? 'Something went wrong while loading data.'} + + ); + } + + if (isEmpty) { + return ( + + {emptyMessage} + + ); + } + + return <>{children}; +} diff --git a/frontend_focused/frontend/components/StatusChip.tsx b/frontend_focused/frontend/components/StatusChip.tsx new file mode 100644 index 00000000..bacdc944 --- /dev/null +++ b/frontend_focused/frontend/components/StatusChip.tsx @@ -0,0 +1,45 @@ +import { Chip } from '@mui/material'; + +import { formatPriority, formatStatus } from '@/lib/format'; +import { SubmissionPriority, SubmissionStatus } from '@/lib/types'; + +const STATUS_COLORS: Record< + SubmissionStatus, + 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' +> = { + new: 'info', + in_review: 'warning', + closed: 'success', + lost: 'error', +}; + +const PRIORITY_COLORS: Record< + SubmissionPriority, + 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' +> = { + high: 'error', + medium: 'warning', + low: 'default', +}; + +export function StatusChip({ status }: { status: SubmissionStatus }) { + return ( + + ); +} + +export function PriorityChip({ priority }: { priority: SubmissionPriority }) { + return ( + + ); +} diff --git a/frontend_focused/frontend/lib/format.ts b/frontend_focused/frontend/lib/format.ts new file mode 100644 index 00000000..1c9c56ba --- /dev/null +++ b/frontend_focused/frontend/lib/format.ts @@ -0,0 +1,41 @@ +import { SubmissionPriority, SubmissionStatus } from '@/lib/types'; + +const STATUS_LABELS: Record = { + new: 'New', + in_review: 'In Review', + closed: 'Closed', + lost: 'Lost', +}; + +const PRIORITY_LABELS: Record = { + high: 'High', + medium: 'Medium', + low: 'Low', +}; + +export function formatStatus(status: SubmissionStatus): string { + return STATUS_LABELS[status] ?? status; +} + +export function formatPriority(priority: SubmissionPriority): string { + return PRIORITY_LABELS[priority] ?? priority; +} + +export function formatDate(value: string): string { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +export function formatRelativeDate(value: string): string { + const date = new Date(value); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + return formatDate(value); +} diff --git a/frontend_focused/frontend/lib/hooks/useBrokerOptions.ts b/frontend_focused/frontend/lib/hooks/useBrokerOptions.ts index 4574af18..ac8769c1 100644 --- a/frontend_focused/frontend/lib/hooks/useBrokerOptions.ts +++ b/frontend_focused/frontend/lib/hooks/useBrokerOptions.ts @@ -14,6 +14,6 @@ export function useBrokerOptions() { return useQuery({ queryKey: ['brokers'], queryFn: fetchBrokers, - enabled: false, + staleTime: 5 * 60_000, }); } diff --git a/frontend_focused/frontend/lib/hooks/useSubmissions.ts b/frontend_focused/frontend/lib/hooks/useSubmissions.ts index 297d22f4..d1ae9669 100644 --- a/frontend_focused/frontend/lib/hooks/useSubmissions.ts +++ b/frontend_focused/frontend/lib/hooks/useSubmissions.ts @@ -19,6 +19,11 @@ async function fetchSubmissions(filters: SubmissionListFilters) { status: filters.status, brokerId: filters.brokerId, companySearch: filters.companySearch, + createdFrom: filters.createdFrom, + createdTo: filters.createdTo, + hasDocuments: filters.hasDocuments, + hasNotes: filters.hasNotes, + page: filters.page, }, }); return response.data; @@ -37,7 +42,7 @@ export function useSubmissionsList(filters: SubmissionListFilters) { return useQuery({ queryKey: [SUBMISSIONS_QUERY_KEY, filters] as QueryKey, queryFn: () => fetchSubmissions(filters), - enabled: false, + placeholderData: (previous) => previous, }); } @@ -45,7 +50,7 @@ export function useSubmissionDetail(id: string | number) { return useQuery({ queryKey: [SUBMISSIONS_QUERY_KEY, id], queryFn: () => fetchSubmissionDetail(id), - enabled: false, + enabled: Boolean(id), staleTime: 60_000, }); } diff --git a/frontend_focused/frontend/lib/types.ts b/frontend_focused/frontend/lib/types.ts index d845fd78..34fc001e 100644 --- a/frontend_focused/frontend/lib/types.ts +++ b/frontend_focused/frontend/lib/types.ts @@ -84,4 +84,9 @@ export interface SubmissionListFilters { status?: SubmissionStatus; brokerId?: string; companySearch?: string; + createdFrom?: string; + createdTo?: string; + hasDocuments?: boolean; + hasNotes?: boolean; + page?: number; }