diff --git a/.env.example b/.env.example index 9e710455..95b2d9c6 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ JOB_RETRY_MAX_SECONDS=300 JOB_TIMEOUT_SECONDS=600 JOB_RESULT_TTL_SECONDS=3600 GIG_RECRUITING_STALE_DAYS=7 +GIG_CONTACTED_REMINDER_DAYS=5 GIG_RECRUITING_REMINDER_MAX_AGE_DAYS=90 # Internal transfer storage (optional defaults for host-run app services) diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index 4e008187..ccfe184a 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -283,6 +283,7 @@ type DashboardNotification = { type DashboardNotificationsResponse = { stale_days: number + contacted_reminder_days: number notifications: DashboardNotification[] } @@ -927,6 +928,7 @@ function App() { const [projectQuery, setProjectQuery] = useState("") const [projectStatus, setProjectStatus] = useState(initialProjectDetailId ? "" : "Open") const [staleRecruitingDays, setStaleRecruitingDays] = useState(7) + const [contactedReminderDays, setContactedReminderDays] = useState(5) const [peopleQuery, setPeopleQuery] = useState("") const [peopleMember, setPeopleMember] = useState("") const [peopleFilters, setPeopleFilters] = useState({}) @@ -1757,6 +1759,7 @@ function App() { "/dashboard/api/notifications?limit=20", ) setStaleRecruitingDays(payload.stale_days || 7) + setContactedReminderDays(payload.contacted_reminder_days || 5) setNotifications(payload.notifications || []) } catch (error) { showError(error, "Unable to load notifications") @@ -2550,16 +2553,18 @@ function App() { ) function openNotification(notification: DashboardNotification) { - if (notification.type === "stale_recruiting_gig") { + if (notification.type === "stale_recruiting_gig" || notification.type === "contacted_gig") { + const isContacted = notification.type === "contacted_gig" + const notificationPrefix = isContacted ? "contacted-gig:" : "stale-recruiting:" const gigId = notification.engagement_id || - (notification.id.startsWith("stale-recruiting:") - ? notification.id.slice("stale-recruiting:".length) + (notification.id.startsWith(notificationPrefix) + ? notification.id.slice(notificationPrefix.length) : "") if (gigId) { openGigDetail(gigId) } else { - setGigStatus("recruiting") + setGigStatus(isContacted ? "contacted" : "recruiting") navigate("gigs", true) } } @@ -2797,6 +2802,7 @@ function App() { includeHistorical={gigIncludeHistorical} limit={gigLimit} staleDays={staleRecruitingDays} + contactedReminderDays={contactedReminderDays} canWrite={can("gigs:write")} canSearchCandidates={can("people:read")} canManageLeads={can("people:read")} @@ -3302,6 +3308,7 @@ function FilterChips({ const gigStatuses = [ "lead", "recruiting", + "contacted", "filled", "unknown", "lost", @@ -3339,11 +3346,16 @@ function gigActivityTimestamp(gig: Gig) { return activityTimes.length > 0 ? new Date(Math.max(...activityTimes)).toISOString() : "" } -function staleRecruitingAge(gig: Gig, staleDays: number) { - if (gig.status !== "recruiting") return null - const latestActivity = gigActivityTimestamp(gig) - const age = daysSince(latestActivity) - if (age === null || age < staleDays) return null +function staleGigAge(gig: Gig, recruitingDays: number, contactedDays: number) { + const isContacted = gig.status === "contacted" + if (gig.status !== "recruiting" && !isContacted) return null + const age = daysSince( + isContacted + ? gig.last_status_changed_at || gig.updated_at || gig.created_at + : gigActivityTimestamp(gig), + ) + const reminderDays = isContacted ? contactedDays : recruitingDays + if (age === null || age < reminderDays) return null return age } @@ -5172,6 +5184,7 @@ function GigsView(props: { includeHistorical: boolean limit: number staleDays: number + contactedReminderDays: number canWrite: boolean canSearchCandidates: boolean canManageLeads: boolean @@ -5207,7 +5220,9 @@ function GigsView(props: { acc.total += 1 acc.applications += Number(gig.application_count || 0) acc.interested += Number(gig.interested_count || 0) - if (staleRecruitingAge(gig, props.staleDays) !== null) acc.stale += 1 + if (staleGigAge(gig, props.staleDays, props.contactedReminderDays) !== null) { + acc.stale += 1 + } return acc }, { total: 0, applications: 0, interested: 0, stale: 0 }, @@ -5459,6 +5474,7 @@ function GigsView(props: { crmContactUrl={props.crmContactUrl} crmAttachmentUrl={props.crmAttachmentUrl} staleDays={props.staleDays} + contactedReminderDays={props.contactedReminderDays} onBack={props.onCloseGig} onUpdateStatus={props.onUpdateStatus} onAddApplication={props.onAddApplication} @@ -5476,7 +5492,7 @@ function GigsView(props: { - + @@ -5516,6 +5532,7 @@ function GigsView(props: { loading={props.loading} canWrite={props.canWrite} staleDays={props.staleDays} + contactedReminderDays={props.contactedReminderDays} onOpenGig={props.onOpenGig} onUpdateStatus={props.onUpdateStatus} /> @@ -5879,6 +5896,7 @@ function GigListItem({ onOpenGig, onUpdateStatus, staleDays, + contactedReminderDays, }: { gig: Gig loading: Record @@ -5886,21 +5904,22 @@ function GigListItem({ onOpenGig: (gigId: string) => void onUpdateStatus: (gigId: string, status: string) => void staleDays: number + contactedReminderDays: number }) { const applications = Array.isArray(gig.applications) ? gig.applications : [] - const isRecruiting = gig.status === "recruiting" + const isActive = gig.status === "recruiting" || gig.status === "contacted" 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) + const staleAge = staleGigAge(gig, staleDays, contactedReminderDays) return (
@@ -5921,14 +5940,14 @@ function GigListItem({ ? "succeeded" : gig.status === "lost" || gig.status === "duplicate" ? "failed" - : isRecruiting + : isActive ? "queued" : "neutral" } > {gig.status_label || titleCase(gig.status)} - {!isRecruiting ? Not recruiting : null} + {!isActive ? Not active : null} {staleAge !== null ? {staleAge}d stale : null}
@@ -6027,6 +6046,7 @@ function GigDetailPage({ crmContactUrl, crmAttachmentUrl, staleDays, + contactedReminderDays, onBack, onUpdateStatus, onAddApplication, @@ -6039,6 +6059,7 @@ function GigDetailPage({ crmContactUrl: (contactId?: string) => string crmAttachmentUrl: (attachmentId?: string) => string staleDays: number + contactedReminderDays: number onBack: () => void onUpdateStatus: (gigId: string, status: string) => void onAddApplication: (gigId: string, crmProfile: string) => Promise @@ -6050,7 +6071,7 @@ function GigDetailPage({ const [candidateSearchError, setCandidateSearchError] = useState("") const [crmProfile, setCrmProfile] = useState("") const applications = Array.isArray(gig.applications) ? gig.applications : [] - const isRecruiting = gig.status === "recruiting" + const isActive = gig.status === "recruiting" || gig.status === "contacted" const candidateQueryReady = candidateQuery.trim().length >= 2 const selectedCandidateId = selectedCandidate?.crm_contact_id || "" const candidateProfile = selectedCandidateId @@ -6063,7 +6084,7 @@ function GigDetailPage({ gig.discord_guild_id, )}/${encodeURIComponent(gig.discord_thread_id)}` : "" - const staleAge = staleRecruitingAge(gig, staleDays) + const staleAge = staleGigAge(gig, staleDays, contactedReminderDays) useEffect(() => { if (!canWrite || !canSearchCandidates) return @@ -6116,9 +6137,7 @@ function GigDetailPage({ return (
- +