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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 148 additions & 13 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1822,7 +1822,7 @@ function App() {
async function addGigApplication(gigId: string, crmProfile: string) {
const normalizedProfile = crmProfile.trim()
if (!normalizedProfile) {
showToast("Paste a CRM Contact profile first", "warning")
showToast("Choose a candidate first", "warning")
return false
}
setBusy(`gig:${gigId}:addCandidate`, true)
Expand Down Expand Up @@ -2798,6 +2798,7 @@ function App() {
limit={gigLimit}
staleDays={staleRecruitingDays}
canWrite={can("gigs:write")}
canSearchCandidates={can("people:read")}
canManageLeads={can("people:read")}
canIncludeHistorical={can("people:read")}
crmContactUrl={crmContactUrl}
Expand Down Expand Up @@ -5172,6 +5173,7 @@ function GigsView(props: {
limit: number
staleDays: number
canWrite: boolean
canSearchCandidates: boolean
canManageLeads: boolean
canIncludeHistorical: boolean
crmContactUrl: (contactId?: string) => string
Expand Down Expand Up @@ -5449,9 +5451,11 @@ function GigsView(props: {
<>
{filterBar}
<GigDetailPage
key={props.selectedGig.id}
gig={props.selectedGig}
loading={props.loading}
canWrite={props.canWrite}
canSearchCandidates={props.canSearchCandidates}
crmContactUrl={props.crmContactUrl}
crmAttachmentUrl={props.crmAttachmentUrl}
staleDays={props.staleDays}
Expand Down Expand Up @@ -6011,10 +6015,15 @@ function candidateDisplayName(application: GigApplication) {
)
}

function personSearchLabel(person: Person) {
return person.name || person.email_508 || person.email || person.crm_contact_id || "CRM person"
}

function GigDetailPage({
gig,
loading,
canWrite,
canSearchCandidates,
crmContactUrl,
crmAttachmentUrl,
staleDays,
Expand All @@ -6026,6 +6035,7 @@ function GigDetailPage({
gig: Gig
loading: Record<string, boolean>
canWrite: boolean
canSearchCandidates: boolean
crmContactUrl: (contactId?: string) => string
crmAttachmentUrl: (attachmentId?: string) => string
staleDays: number
Expand All @@ -6034,16 +6044,76 @@ function GigDetailPage({
onAddApplication: (gigId: string, crmProfile: string) => Promise<boolean>
onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void
}) {
const [candidateQuery, setCandidateQuery] = useState("")
const [candidateMatches, setCandidateMatches] = useState<Person[]>([])
const [selectedCandidate, setSelectedCandidate] = useState<Person | null>(null)
const [candidateSearchError, setCandidateSearchError] = useState("")
const [crmProfile, setCrmProfile] = useState("")
const applications = Array.isArray(gig.applications) ? gig.applications : []
const isRecruiting = gig.status === "recruiting"
const candidateQueryReady = candidateQuery.trim().length >= 2
const selectedCandidateId = selectedCandidate?.crm_contact_id || ""
const candidateProfile = selectedCandidateId
? crmContactUrl(selectedCandidateId) || selectedCandidateId
: ""
const profileForAdd = canSearchCandidates ? candidateProfile : crmProfile.trim()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep manual CRM entry for unsynced contacts

Users with people:read now get profileForAdd only from selectedCandidate, so the CRM URL input is hidden for admins. When a CRM contact is new or otherwise not yet in the local people cache, /dashboard/api/people cannot return it because the lookup selects FROM people (apps/api/src/five08/backend/api.py:2204), even though dashboard_add_gig_application_handler still accepts a crm_profile and verifies it directly against Espo (apps/api/src/five08/backend/api.py:7082, apps/api/src/five08/backend/api.py:7099). In that unsynced-contact case an admin can no longer add a valid candidate; keep a manual CRM profile fallback alongside search.

Useful? React with 👍 / 👎.

const threadUrl =
gig.discord_guild_id && gig.discord_thread_id
? `https://discord.com/channels/${encodeURIComponent(
gig.discord_guild_id,
)}/${encodeURIComponent(gig.discord_thread_id)}`
: ""
const staleAge = staleRecruitingAge(gig, staleDays)

useEffect(() => {
if (!canWrite || !canSearchCandidates) return
const query = candidateQuery.trim()
if (selectedCandidate && query === personSearchLabel(selectedCandidate)) {
setCandidateMatches([])
setCandidateSearchError("")
return
}
if (query.length < 2) {
setCandidateMatches([])
setCandidateSearchError("")
return
}

const controller = new AbortController()
const timer = window.setTimeout(() => {
const params = new URLSearchParams({ limit: "8", query })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter inactive people out of candidate search

When the people cache contains missing_in_crm or conflict rows for a matching name, this typeahead omits the endpoint's sync_status=active filter, so stale CRM IDs can be offered as addable candidates; selecting one then reaches the add handler's live Espo lookup and fails with crm_profile_not_found (apps/api/src/five08/backend/api.py:7099). If enough stale rows match, they can also consume the 8-result limit before the valid active contact is shown, so pass the active filter for this picker.

Useful? React with 👍 / 👎.

void requestJson<Person[]>(`/dashboard/api/people?${params.toString()}`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve candidate adds for gig-only members

For Discord Member sessions, /dashboard/api/me grants gigs:read/gigs:write but not people:read, and dashboard_people_handler requires people:read. Since this new search always calls /dashboard/api/people and the form disables submit until a result is selected, gig owners who could previously paste a CRM URL and pass dashboard_add_gig_application_handler can no longer add candidates; the search returns 403 and no candidateProfile is ever set. Gate the search UI on people:read with a fallback or use a gigs-write candidate lookup.

Useful? React with 👍 / 👎.

signal: controller.signal,
})
.then((people) => {
setCandidateMatches(people.filter((person) => Boolean(person.crm_contact_id)))
Comment on lines +6088 to +6089

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard search results against stale queries

Fresh evidence: although the updated onChange clears matches, this fulfillment handler still commits whichever request resolves without checking controller.signal.aborted or that its query is still the current input. If a prior search response for one query completes after the user has typed another query, stale candidates can reappear under the new input and be selected/added before the newer request wins.

Useful? React with 👍 / 👎.

setCandidateSearchError("")
})
.catch((error) => {
if (
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return
}
setCandidateMatches([])
setCandidateSearchError(messageFromUnknown(error, "Unable to search candidates"))
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, 300)

return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [candidateQuery, canSearchCandidates, canWrite, selectedCandidate])

function chooseCandidate(person: Person) {
setSelectedCandidate(person)
setCandidateQuery(personSearchLabel(person))
setCandidateMatches([])
setCandidateSearchError("")
}

return (
<div className="grid gap-5">
<Card
Expand Down Expand Up @@ -6160,24 +6230,89 @@ function GigDetailPage({
className="grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]"
onSubmit={(event) => {
event.preventDefault()
void onAddApplication(gig.id, crmProfile).then((added) => {
if (added) setCrmProfile("")
void onAddApplication(gig.id, profileForAdd).then((added) => {
if (added) {
setCandidateQuery("")
setCandidateMatches([])
setSelectedCandidate(null)
setCrmProfile("")
}
})
}}
>
<Label className="min-w-0">
CRM profile
<Input
value={crmProfile}
onChange={(event) => setCrmProfile(event.target.value)}
placeholder="https://crm.508.dev/#Contact/view/..."
aria-label="CRM profile for candidate"
/>
</Label>
{canSearchCandidates ? (
<div className="relative min-w-0">
<Label>
Candidate
<Input
value={candidateQuery}
autoComplete="off"
placeholder="Search by name or email"
aria-label="Search candidates to add"
onChange={(event) => {
setCandidateQuery(event.target.value)
setCandidateMatches([])
setCandidateSearchError("")
setSelectedCandidate(null)
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || candidateMatches.length !== 1) return
event.preventDefault()
chooseCandidate(candidateMatches[0])
}}
/>
</Label>
{candidateQueryReady && !selectedCandidate ? (
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg">
{candidateSearchError ? (
<div className="px-3 py-2 text-sm text-destructive">
{candidateSearchError}
</div>
) : candidateMatches.length ? (
candidateMatches.map((person) => {
const label = personSearchLabel(person)
const detail = [person.email_508 || person.email, person.contact_type]
.filter(Boolean)
.join(" | ")
return (
<button
key={person.crm_contact_id}
type="button"
className="grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none"
onClick={() => chooseCandidate(person)}
>
<span className="truncate text-sm font-bold">{label}</span>
{detail ? (
<span className="truncate text-xs text-muted-foreground">
{detail}
</span>
) : null}
</button>
)
})
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
No candidates match this search
</div>
)}
</div>
) : null}
</div>
) : (
<Label className="min-w-0">
CRM profile
<Input
value={crmProfile}
onChange={(event) => setCrmProfile(event.target.value)}
placeholder="https://crm.508.dev/#Contact/view/..."
aria-label="CRM profile for candidate"
/>
</Label>
)}
<Button
type="submit"
className="self-end"
disabled={loading[`gig:${gig.id}:addCandidate`] || !crmProfile.trim()}
disabled={loading[`gig:${gig.id}:addCandidate`] || !profileForAdd}
>
<UserPlus />
Add candidate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"index.html": {
"file": "assets/index-_nigGMGP.js",
"file": "assets/index-BRLPZTtg.js",
"name": "index",
"src": "index.html",
"isEntry": true,
Expand Down

Large diffs are not rendered by default.

This file was deleted.

2 changes: 1 addition & 1 deletion apps/api/src/five08/backend/static/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>508 Operations Dashboard</title>
<script type="module" crossorigin src="/dashboard/assets/index-_nigGMGP.js"></script>
<script type="module" crossorigin src="/dashboard/assets/index-BRLPZTtg.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-2UypYUrJ.css">
</head>
<body>
Expand Down
24 changes: 20 additions & 4 deletions tests/integration/test_dashboard_playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ def _people_payload() -> list[dict[str, object]]:
]


def _candidate_search_payload() -> list[dict[str, object]]:
return [
{
"crm_contact_id": "contact-candidate-2",
"name": "Devon Candidate",
"email": "devon@example.com",
"contact_type": "Prospect",
}
]


def _onboarding_payload() -> list[dict[str, object]]:
return [
{
Expand Down Expand Up @@ -465,10 +476,15 @@ def job_detail_route(route: Any) -> None:

def people_route(route: Any) -> None:
people_requests.append(route.request.url)
query = parse_qs(urlparse(route.request.url).query).get("query", [""])[0]
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(_people_payload()),
body=json.dumps(
_candidate_search_payload()
if query == "Devon"
else _people_payload()
),
)

def onboarding_route(route: Any) -> None:
Expand Down Expand Up @@ -911,9 +927,9 @@ def sync_route(route: Any) -> None:
"href",
f"{crm_base_url}/#Contact/view/contact-candidate-1",
)
page.get_by_label("CRM profile for candidate").fill(
f"{crm_base_url}/#Contact/view/contact-candidate-2"
)
page.get_by_label("Search candidates to add").fill("Devon")
page.get_by_text("Devon Candidate", exact=True).click()
assert any("query=Devon" in url for url in people_requests)
page.get_by_role("button", name="Add candidate").click()
assert gig_application_add_requested.wait(timeout=5)
page.get_by_text("Devon Candidate").wait_for()
Expand Down