From e50a6c214418d1c86af2a2b4f5b30f1f87bc942d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 15 Jul 2026 18:18:33 +0100 Subject: [PATCH 1/2] Add candidate blurb library and drafting workflows --- apps/admin_dashboard/src/main.tsx | 826 +++++++++ apps/api/src/five08/backend/api.py | 595 ++++++- apps/api/src/five08/backend/routes.py | 42 + apps/api/src/five08/backend/schemas.py | 42 + .../src/five08/discord_bot/cogs/jobs.py | 1158 +++++++++++++ .../src/five08/worker/crm/people_sync.py | 3 +- .../20260715_0100_create_candidate_blurbs.py | 210 +++ docs/discord-gig-dashboard.md | 34 + packages/shared/src/five08/audit.py | 6 +- .../shared/src/five08/candidate_blurbs.py | 1504 +++++++++++++++++ tests/unit/test_backend_api.py | 274 +++ tests/unit/test_candidate_blurbs.py | 361 ++++ tests/unit/test_jobs.py | 142 ++ tests/unit/test_shared_audit.py | 32 +- tests/unit/test_worker_people_sync.py | 5 + 15 files changed, 5229 insertions(+), 5 deletions(-) create mode 100644 apps/worker/src/five08/worker/migrations/versions/20260715_0100_create_candidate_blurbs.py create mode 100644 packages/shared/src/five08/candidate_blurbs.py create mode 100644 tests/unit/test_candidate_blurbs.py diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index ccfe184a..59d15758 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -89,6 +89,8 @@ type User = { email?: string display_name?: string actor_provider?: string + groups?: string[] + is_admin?: boolean crm_contact_id?: string crm_base_url?: string permissions?: string[] @@ -205,6 +207,51 @@ type GigApplication = { latest_resume_name?: string skills_count?: number is_member?: boolean + blurbs?: CandidateBlurb[] +} + +type CandidateBlurbScope = "general" | "gig" +type CandidateBlurbAuthorKind = "candidate" | "candidate_attributed" | "team" | "ai" + +type CandidateBlurb = { + id: string + text: string + scope: CandidateBlurbScope + author_kind: CandidateBlurbAuthorKind + source?: string + status?: "draft" | "approved" | "superseded" + person_id?: string + crm_contact_id?: string + discord_user_id?: string + application_id?: string + engagement_id?: string + submitted_by_discord_user_id?: string + metadata?: Record + created_at?: string + updated_at?: string + is_current?: boolean +} + +type CandidateBlurbIdentity = Pick< + CandidateBlurb, + "person_id" | "crm_contact_id" | "discord_user_id" +> + +type CandidateBlurbDraft = { + text: string + supporting_facts?: string[] + missing_facts?: string[] + metadata?: Record +} + +type CandidateBlurbSavePayload = { + text: string + scope: CandidateBlurbScope + author_kind: CandidateBlurbAuthorKind + source: "dashboard" + status: "draft" | "approved" + generation_metadata?: Record + replaces_blurb_id?: string } type Gig = { @@ -229,6 +276,7 @@ type Gig = { application_count?: number interested_count?: number applications?: GigApplication[] + candidate_blurbs?: CandidateBlurb[] } type JobLead = { @@ -955,6 +1003,16 @@ function App() { return can(permission) || canDryRun(permission) } + function canManageCandidateBlurbs() { + if (user?.is_admin) return true + return (user?.groups || []).some((group) => { + const normalizedGroup = group.trim().toLowerCase() + return ["steering committee", "workflows engineer", "admin", "owner"].includes( + normalizedGroup, + ) + }) + } + function canView(nextView: View) { return can(routePermissions[nextView]) } @@ -1850,6 +1908,186 @@ function App() { } } + async function loadPersonBlurbs(contactId: string): Promise { + const normalizedContactId = contactId.trim() + if (!normalizedContactId) return [] + const busyKey = `person:${normalizedContactId}:blurbs` + setBusy(busyKey, true) + try { + return await requestJson( + `/dashboard/api/people/${encodeURIComponent(normalizedContactId)}/blurbs`, + ) + } catch (error) { + showError(error, "Unable to load candidate blurbs") + return [] + } finally { + setBusy(busyKey, false) + } + } + + async function savePersonBlurb( + contactId: string, + payload: CandidateBlurbSavePayload, + ): Promise { + const normalizedContactId = contactId.trim() + if (!normalizedContactId) { + showToast("Missing CRM contact id", "error") + return null + } + const busyKey = `person:${normalizedContactId}:blurb:save` + setBusy(busyKey, true) + try { + const result = await requestJson( + `/dashboard/api/people/${encodeURIComponent(normalizedContactId)}/blurbs`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, scope: "general" }), + }, + ) + showToast("Saved candidate blurb", "ok") + return result + } catch (error) { + showError(error, "Unable to save candidate blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + + async function draftPersonBlurb(contactId: string): Promise { + const normalizedContactId = contactId.trim() + if (!normalizedContactId) { + showToast("Missing CRM contact id", "error") + return null + } + const busyKey = `person:${normalizedContactId}:blurb:draft` + setBusy(busyKey, true) + try { + return await requestJson( + `/dashboard/api/people/${encodeURIComponent(normalizedContactId)}/blurbs/draft`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scope: "general" }), + }, + ) + } catch (error) { + showError(error, "Unable to draft candidate blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + + async function saveGigApplicationBlurb( + gigId: string, + applicationId: string, + payload: CandidateBlurbSavePayload, + ): Promise { + const busyKey = `application:${applicationId}:blurb:save` + setBusy(busyKey, true) + try { + const result = await requestJson( + `/dashboard/api/gigs/${encodeURIComponent(gigId)}/applications/${encodeURIComponent( + applicationId, + )}/blurbs`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ) + showToast( + payload.scope === "gig" ? "Saved gig blurb" : "Saved reusable candidate blurb", + "ok", + ) + if (selectedGigId === gigId) await loadGigDetail(gigId) + return result + } catch (error) { + showError(error, "Unable to save candidate blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + + async function draftGigApplicationBlurb( + gigId: string, + applicationId: string, + scope: CandidateBlurbScope, + ): Promise { + const busyKey = `application:${applicationId}:blurb:draft` + setBusy(busyKey, true) + try { + return await requestJson( + `/dashboard/api/gigs/${encodeURIComponent(gigId)}/applications/${encodeURIComponent( + applicationId, + )}/blurbs/draft`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scope }), + }, + ) + } catch (error) { + showError(error, "Unable to draft candidate blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + + async function saveUnattachedGigBlurb( + gigId: string, + identity: CandidateBlurbIdentity, + payload: CandidateBlurbSavePayload, + ): Promise { + const busyKey = `gig:${gigId}:unattached-blurb:save:${candidateBlurbIdentityKey(identity)}` + setBusy(busyKey, true) + try { + const result = await requestJson( + `/dashboard/api/gigs/${encodeURIComponent(gigId)}/blurbs`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, scope: "gig", ...identity }), + }, + ) + showToast("Saved gig blurb", "ok") + if (selectedGigId === gigId) await loadGigDetail(gigId) + return result + } catch (error) { + showError(error, "Unable to save gig blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + + async function draftUnattachedGigBlurb( + gigId: string, + identity: CandidateBlurbIdentity, + ): Promise { + const busyKey = `gig:${gigId}:unattached-blurb:draft:${candidateBlurbIdentityKey(identity)}` + setBusy(busyKey, true) + try { + return await requestJson( + `/dashboard/api/gigs/${encodeURIComponent(gigId)}/blurbs/draft`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scope: "gig", ...identity }), + }, + ) + } catch (error) { + showError(error, "Unable to draft gig blurb") + return null + } finally { + setBusy(busyKey, false) + } + } + function peopleUrl() { const params = new URLSearchParams({ limit: "25" }) if (peopleQuery.trim()) params.set("query", peopleQuery.trim()) @@ -2738,6 +2976,7 @@ function App() { people={sortedPeople} sort={sort.people} canSync={canUse("people:sync")} + canManageBlurbs={canManageCandidateBlurbs()} loading={loading} peopleQuery={peopleQuery} peopleMember={peopleMember} @@ -2767,6 +3006,9 @@ function App() { }} crmContactUrl={crmContactUrl} crmAttachmentUrl={crmAttachmentUrl} + onLoadBlurbs={loadPersonBlurbs} + onSaveBlurb={savePersonBlurb} + onDraftBlurb={draftPersonBlurb} /> ) : null} @@ -2806,6 +3048,7 @@ function App() { canWrite={can("gigs:write")} canSearchCandidates={can("people:read")} canManageLeads={can("people:read")} + canManageBlurbs={canManageCandidateBlurbs()} canIncludeHistorical={can("people:read")} crmContactUrl={crmContactUrl} crmAttachmentUrl={crmAttachmentUrl} @@ -2822,6 +3065,10 @@ function App() { onUpdateStatus={updateGigStatus} onAddApplication={addGigApplication} onUpdateApplicationStatus={updateGigApplicationStatus} + onSaveApplicationBlurb={saveGigApplicationBlurb} + onDraftApplicationBlurb={draftGigApplicationBlurb} + onSaveUnattachedGigBlurb={saveUnattachedGigBlurb} + onDraftUnattachedGigBlurb={draftUnattachedGigBlurb} onSyncLeads={syncGigLeads} onReviewLead={reviewGigLead} onPostLead={postGigLead} @@ -5188,6 +5435,7 @@ function GigsView(props: { canWrite: boolean canSearchCandidates: boolean canManageLeads: boolean + canManageBlurbs: boolean canIncludeHistorical: boolean crmContactUrl: (contactId?: string) => string crmAttachmentUrl: (attachmentId?: string) => string @@ -5204,6 +5452,25 @@ function GigsView(props: { onUpdateStatus: (gigId: string, status: string) => void onAddApplication: (gigId: string, crmProfile: string) => Promise onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void + onSaveApplicationBlurb: ( + gigId: string, + applicationId: string, + payload: CandidateBlurbSavePayload, + ) => Promise + onDraftApplicationBlurb: ( + gigId: string, + applicationId: string, + scope: CandidateBlurbScope, + ) => Promise + onSaveUnattachedGigBlurb: ( + gigId: string, + identity: CandidateBlurbIdentity, + payload: CandidateBlurbSavePayload, + ) => Promise + onDraftUnattachedGigBlurb: ( + gigId: string, + identity: CandidateBlurbIdentity, + ) => Promise onSyncLeads: () => void onReviewLead: (leadId: string, status: "approved" | "rejected") => void onPostLead: ( @@ -5471,6 +5738,7 @@ function GigsView(props: { loading={props.loading} canWrite={props.canWrite} canSearchCandidates={props.canSearchCandidates} + canManageBlurbs={props.canManageBlurbs} crmContactUrl={props.crmContactUrl} crmAttachmentUrl={props.crmAttachmentUrl} staleDays={props.staleDays} @@ -5479,6 +5747,10 @@ function GigsView(props: { onUpdateStatus={props.onUpdateStatus} onAddApplication={props.onAddApplication} onUpdateApplicationStatus={props.onUpdateApplicationStatus} + onSaveApplicationBlurb={props.onSaveApplicationBlurb} + onDraftApplicationBlurb={props.onDraftApplicationBlurb} + onSaveUnattachedGigBlurb={props.onSaveUnattachedGigBlurb} + onDraftUnattachedGigBlurb={props.onDraftUnattachedGigBlurb} /> ) @@ -6043,6 +6315,7 @@ function GigDetailPage({ loading, canWrite, canSearchCandidates, + canManageBlurbs, crmContactUrl, crmAttachmentUrl, staleDays, @@ -6051,11 +6324,16 @@ function GigDetailPage({ onUpdateStatus, onAddApplication, onUpdateApplicationStatus, + onSaveApplicationBlurb, + onDraftApplicationBlurb, + onSaveUnattachedGigBlurb, + onDraftUnattachedGigBlurb, }: { gig: Gig loading: Record canWrite: boolean canSearchCandidates: boolean + canManageBlurbs: boolean crmContactUrl: (contactId?: string) => string crmAttachmentUrl: (attachmentId?: string) => string staleDays: number @@ -6064,6 +6342,25 @@ function GigDetailPage({ onUpdateStatus: (gigId: string, status: string) => void onAddApplication: (gigId: string, crmProfile: string) => Promise onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void + onSaveApplicationBlurb: ( + gigId: string, + applicationId: string, + payload: CandidateBlurbSavePayload, + ) => Promise + onDraftApplicationBlurb: ( + gigId: string, + applicationId: string, + scope: CandidateBlurbScope, + ) => Promise + onSaveUnattachedGigBlurb: ( + gigId: string, + identity: CandidateBlurbIdentity, + payload: CandidateBlurbSavePayload, + ) => Promise + onDraftUnattachedGigBlurb: ( + gigId: string, + identity: CandidateBlurbIdentity, + ) => Promise }) { const [candidateQuery, setCandidateQuery] = useState("") const [candidateMatches, setCandidateMatches] = useState([]) @@ -6071,6 +6368,9 @@ function GigDetailPage({ const [candidateSearchError, setCandidateSearchError] = useState("") const [crmProfile, setCrmProfile] = useState("") const applications = Array.isArray(gig.applications) ? gig.applications : [] + const unattachedBlurbGroups = groupCandidateBlurbs( + Array.isArray(gig.candidate_blurbs) ? gig.candidate_blurbs : [], + ) const isActive = gig.status === "recruiting" || gig.status === "contacted" const candidateQueryReady = candidateQuery.trim().length >= 2 const selectedCandidateId = selectedCandidate?.crm_contact_id || "" @@ -6347,13 +6647,42 @@ function GigDetailPage({ application={application} loading={loading} canWrite={canWrite} + canManageBlurbs={canManageBlurbs} crmContactUrl={crmContactUrl} crmAttachmentUrl={crmAttachmentUrl} onUpdateApplicationStatus={onUpdateApplicationStatus} + onSaveBlurb={onSaveApplicationBlurb} + onDraftBlurb={onDraftApplicationBlurb} /> ))} + + {canManageBlurbs && unattachedBlurbGroups.length ? ( + + + Captured candidate blurbs + + Saved for this gig without changing candidate status or creating an application. + + +
+ {unattachedBlurbGroups.map((group) => ( + onSaveUnattachedGigBlurb(gig.id, group.identity, payload)} + onDraft={() => onDraftUnattachedGigBlurb(gig.id, group.identity)} + /> + ))} +
+
+ ) : null} ) } @@ -6363,17 +6692,31 @@ function GigApplicationRow({ application, loading, canWrite, + canManageBlurbs, crmContactUrl, crmAttachmentUrl, onUpdateApplicationStatus, + onSaveBlurb, + onDraftBlurb, }: { gigId: string application: GigApplication loading: Record canWrite: boolean + canManageBlurbs: boolean crmContactUrl: (contactId?: string) => string crmAttachmentUrl: (attachmentId?: string) => string onUpdateApplicationStatus: (gigId: string, applicationId: string, status: string) => void + onSaveBlurb: ( + gigId: string, + applicationId: string, + payload: CandidateBlurbSavePayload, + ) => Promise + onDraftBlurb: ( + gigId: string, + applicationId: string, + scope: CandidateBlurbScope, + ) => Promise }) { const displayName = candidateDisplayName(application) const contactUrl = crmContactUrl(application.crm_contact_id) @@ -6447,6 +6790,466 @@ function GigApplicationRow({ ))} ) : null} + {canManageBlurbs ? ( + onSaveBlurb(gigId, application.id, payload)} + onDraft={(scope) => onDraftBlurb(gigId, application.id, scope)} + /> + ) : null} + + ) +} + +function candidateBlurbAuthorLabel(authorKind?: CandidateBlurbAuthorKind) { + switch (authorKind) { + case "candidate": + return "Written by candidate" + case "candidate_attributed": + return "Candidate supplied" + case "team": + return "Written by team" + case "ai": + return "AI draft" + default: + return "Unknown author" + } +} + +function candidateBlurbScopeLabel(scope?: CandidateBlurbScope) { + return scope === "gig" ? "Gig-specific" : "General" +} + +function candidateBlurbSourceLabel(source?: string) { + const value = String(source || "dashboard").replaceAll("_", " ") + return titleCase(value) +} + +function candidateBlurbGenerationMetadata(blurb: CandidateBlurb) { + return blurb.author_kind === "ai" || blurb.metadata?.skill_id === "candidate_blurb_draft" + ? blurb.metadata + : undefined +} + +function candidateBlurbIdentityKey(identity: CandidateBlurbIdentity) { + return ( + identity.person_id || + (identity.crm_contact_id ? `crm:${identity.crm_contact_id}` : "") || + (identity.discord_user_id ? `discord:${identity.discord_user_id}` : "") || + "unknown" + ) +} + +function candidateBlurbIdentityLabel(blurb: CandidateBlurb) { + const metadataName = [ + blurb.metadata?.candidate_name, + blurb.metadata?.candidate_discord_display_name, + ].find((value): value is string => typeof value === "string" && value.trim().length > 0) + return ( + metadataName || + blurb.crm_contact_id || + blurb.discord_user_id || + blurb.person_id || + "Unattached candidate" + ) +} + +type CandidateBlurbGroup = { + key: string + identity: CandidateBlurbIdentity + label: string + blurbs: CandidateBlurb[] +} + +function groupCandidateBlurbs(blurbs: CandidateBlurb[]): CandidateBlurbGroup[] { + const groups = new Map() + for (const blurb of blurbs) { + const identity: CandidateBlurbIdentity = { + person_id: blurb.person_id, + crm_contact_id: blurb.crm_contact_id, + discord_user_id: blurb.discord_user_id, + } + const identityKey = candidateBlurbIdentityKey(identity) + const key = identityKey === "unknown" ? `unknown:${blurb.id}` : identityKey + const group = groups.get(key) + if (group) { + group.blurbs.push(blurb) + continue + } + groups.set(key, { + key, + identity, + label: candidateBlurbIdentityLabel(blurb), + blurbs: [blurb], + }) + } + return [...groups.values()] +} + +function CandidateBlurbPanel({ + candidateLabel, + blurbs, + defaultScope, + allowScopeSelection = false, + loading, + saveBusyKey, + draftBusyKey, + onLoad, + onSave, + onDraft, +}: { + candidateLabel: string + blurbs?: CandidateBlurb[] + defaultScope: CandidateBlurbScope + allowScopeSelection?: boolean + loading: Record + saveBusyKey: string + draftBusyKey: string + onLoad?: () => Promise + onSave: (payload: CandidateBlurbSavePayload) => Promise + onDraft: (scope: CandidateBlurbScope) => Promise +}) { + const [open, setOpen] = useState(false) + const [loaded, setLoaded] = useState(Boolean(blurbs)) + const [items, setItems] = useState(blurbs || []) + const [scope, setScope] = useState(defaultScope) + const [authorKind, setAuthorKind] = useState("candidate_attributed") + const [status, setStatus] = useState<"draft" | "approved">("approved") + const [text, setText] = useState("") + const [showHistory, setShowHistory] = useState(false) + const [copyStatus, setCopyStatus] = useState("") + const [draft, setDraft] = useState(null) + const [generationMetadata, setGenerationMetadata] = useState< + Record | undefined + >() + const [replacesBlurbId, setReplacesBlurbId] = useState("") + + useEffect(() => { + if (!blurbs) return + setItems(blurbs) + setLoaded(true) + }, [blurbs]) + + const orderedBlurbs = useMemo( + () => + [...items].sort((left, right) => { + if (Boolean(left.is_current) !== Boolean(right.is_current)) { + return left.is_current ? -1 : 1 + } + return String(right.created_at || "").localeCompare(String(left.created_at || "")) + }), + [items], + ) + const blurbsForSelectedScope = allowScopeSelection + ? orderedBlurbs.filter((item) => item.scope === scope) + : orderedBlurbs + const currentBlurb = + blurbsForSelectedScope.find((item) => item.is_current && item.status === "approved") || + blurbsForSelectedScope.find((item) => item.is_current) || + blurbsForSelectedScope.find((item) => item.status === "approved") || + blurbsForSelectedScope[0] + const history = blurbsForSelectedScope.filter((item) => item.id !== currentBlurb?.id) + const savedSampleCount = blurbsForSelectedScope.length + const saving = Boolean(loading[saveBusyKey]) + const drafting = Boolean(loading[draftBusyKey]) + + async function toggleOpen() { + const nextOpen = !open + setOpen(nextOpen) + if (!nextOpen || loaded || !onLoad) return + const result = await onLoad() + setItems(result) + setLoaded(true) + } + + async function copyCurrentBlurb() { + if (!currentBlurb?.text) return + try { + if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable") + await navigator.clipboard.writeText(currentBlurb.text) + setCopyStatus("Copied") + } catch { + setCopyStatus("Copy unavailable") + } + } + + async function save() { + const normalizedText = text.trim() + if (!normalizedText) return + const result = await onSave({ + text: normalizedText, + scope, + author_kind: authorKind, + source: "dashboard", + status, + generation_metadata: generationMetadata, + replaces_blurb_id: replacesBlurbId || undefined, + }) + if (!result) return + setItems((current) => [ + result, + ...current + .filter((item) => item.id !== result.id) + .map((item) => + item.id === replacesBlurbId + ? { ...item, is_current: false, status: "superseded" as const } + : item, + ), + ]) + setText("") + setDraft(null) + setGenerationMetadata(undefined) + setReplacesBlurbId("") + setShowHistory(true) + } + + async function draftBlurb() { + const result = await onDraft(scope) + if (!result?.text?.trim()) return + setText(result.text) + setAuthorKind("ai") + setStatus("draft") + setDraft(result) + setGenerationMetadata(result.metadata) + setReplacesBlurbId("") + } + + function editBlurb(blurb: CandidateBlurb) { + setText(blurb.text) + setScope(blurb.scope) + setAuthorKind(blurb.author_kind) + setStatus(blurb.status === "draft" ? "draft" : "approved") + setGenerationMetadata(candidateBlurbGenerationMetadata(blurb)) + setDraft(null) + setReplacesBlurbId(blurb.id) + } + + function editCurrentBlurb() { + if (currentBlurb) editBlurb(currentBlurb) + } + + return ( +
+
+
+ Candidate blurbs + + {savedSampleCount + ? `${savedSampleCount} saved sample${savedSampleCount === 1 ? "" : "s"}` + : "No saved samples"} + +
+ +
+ + {open ? ( +
+ {currentBlurb ? ( +
+
+ + {candidateBlurbScopeLabel(currentBlurb.scope)} + + + {candidateBlurbAuthorLabel(currentBlurb.author_kind)} + + {currentBlurb.status ? ( + + {titleCase(currentBlurb.status)} + + ) : null} + + {candidateBlurbSourceLabel(currentBlurb.source)} + {currentBlurb.created_at ? ` · ${formatDate(currentBlurb.created_at)}` : ""} + +
+

{currentBlurb.text}

+
+ + {copyStatus ? ( + {copyStatus} + ) : null} + {history.length ? ( + + ) : null} + +
+
+ ) : ( +

+ Paste a candidate-supplied blurb or generate a reviewable draft for {candidateLabel}. +

+ )} + + {showHistory && history.length ? ( +
+ {history.map((item) => ( +
+
+ {candidateBlurbScopeLabel(item.scope)} + {candidateBlurbAuthorLabel(item.author_kind)} + {item.status ? {titleCase(item.status)} : null} + {formatDate(item.created_at) || "Unknown date"} +
+

{item.text}

+ {item.is_current ? ( +
+ +
+ ) : null} +
+ ))} +
+ ) : null} + +
+
+ {allowScopeSelection ? ( + + ) : ( +
+ Save as + + {defaultScope === "gig" ? "This gig" : "Reusable general sample"} + +
+ )} + + +
+