diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index 743145d..99146fe 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -1,5 +1,6 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +import logging from job.assignment.models import ApplicationAssignments, JobAssignments from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment @@ -18,6 +19,8 @@ JOB_OWNER_COLUMN = { "hiring_manager": "hiring_manager_id", } +logger = logging.getLogger(__name__) + class Assignment: def __init__(self,session:AsyncSession): @@ -102,7 +105,21 @@ class Assignment: assigned_by=current_user.get("id") if isinstance(current_user,dict) else None row=await self.record_job_owner(job_post_id,user_id,role,assigned_by) column=JOB_OWNER_COLUMN[role] - await JobPosts.update_job_post(self.session,job_post_id,{column:user_id}) + updated=await JobPosts.update_job_post(self.session,job_post_id,{column:user_id}) + if updated: + try: + from notifications.views import notify_job_assignment + label="hiring manager" if role=="hiring_manager" else "recruiter" + await notify_job_assignment( + self.session,updated, + role_label=label, + actor_id=assigned_by, + previous_ids=[ + job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id + ], + ) + except Exception as exc: + logger.warning("notification insert skipped: %s", exc) names=await Users.names_by_ids( self.session,[row.user_id,row.assigned_by] if row else [], ) diff --git a/backend/job/history/views.py b/backend/job/history/views.py index bab10e5..37d10f9 100644 --- a/backend/job/history/views.py +++ b/backend/job/history/views.py @@ -111,7 +111,19 @@ class HistoryRecorder: "actor_kind": actor_kind or "user", "meta": meta, } - return await CandidateHistory.insert_event(self.session, fields, commit=commit) + row = await CandidateHistory.insert_event(self.session, fields, commit=commit) + try: + from notifications.views import notify_candidate_history + await notify_candidate_history( + self.session, + row, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_upload_candidate_id, + commit=commit, + ) + except Exception: + logger.exception("candidate history notification failed for %s", event_type) + return row except Exception: logger.exception("candidate history record failed for %s", event_type) if commit: diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index de109e5..99fd26a 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -191,6 +191,12 @@ class JobPost: if rec: await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) + try: + from notifications.views import notify_job_created + await notify_job_created(self.session,row,actor_id=assigned_by) + except Exception as exc: + logger.warning("notification insert skipped: %s",exc) + if not publish: return serialize_job_post(row) @@ -415,6 +421,22 @@ class JobPost: await assignment.record_job_owner( job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by, ) + if hm_changed or rec_changed: + try: + from notifications.views import notify_job_assignment + labels=[] + if hm_changed: + labels.append("hiring manager") + if rec_changed: + labels.append("recruiter") + await notify_job_assignment( + self.session,row, + role_label=" and ".join(labels), + actor_id=assigned_by, + previous_ids=[existing.hiring_manager_id,existing.current_recruiter_id], + ) + except Exception as exc: + logger.warning("notification insert skipped: %s",exc) return await self._job_row(row) async def delete_job(self,job_post_id,current_user): @@ -474,25 +496,22 @@ class JobPost: ) status=parsed.value actor=current_user.get("id") if isinstance(current_user,dict) else None + previous=None + existing=await JobPosts.get_job_post_by_id(self.session,job_post_id) + if existing: + previous=existing.requisition_status row=await JobPosts.set_requisition_status( self.session,job_post_id,status,changed_by=actor, ) if not row: raise HTTPException(status_code=404,detail="Job post not found") - if status==RequisitionStatus.CLOSED.value: + if previous!=status: try: - from notifications.models import Notifications - raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) - recipient=uuid.UUID(str(raw)) if raw else None - if recipient: - await Notifications.insert_notification(self.session,{ - "user_id":recipient, - "kind":"approval", - "title":"Requisition closed", - "body":f"{row.title} was closed", - "link_path":"/jobs", - "job_post_id":row.id, - }) + from notifications.views import notify_job_status + await notify_job_status( + self.session,row, + from_status=previous,to_status=status,actor_id=actor, + ) except Exception as exc: logger.warning("notification insert skipped: %s",exc) return await self._job_row(row) diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 9b66469..f5a7edb 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -175,6 +175,30 @@ class Notifications(SQLModel, table=True): await session.commit() return await cls.get_by_id(session, row.id) + @classmethod + async def insert_many(cls, session: AsyncSession, payloads, *, commit: bool = True): + """One row per payload. `commit=False` rides the caller's transaction.""" + rows = [] + for fields in payloads or []: + uid = cls._as_uuid(fields.get("user_id")) + if uid is None or not fields.get("kind") or not fields.get("title"): + continue + job_id = cls._as_uuid(fields.get("job_post_id")) if fields.get("job_post_id") else None + row = cls( + user_id=uid, + kind=fields["kind"], + title=fields["title"], + body=fields.get("body"), + link_path=fields.get("link_path"), + inbox_id=fields.get("inbox_id"), + job_post_id=job_id, + ) + session.add(row) + rows.append(row) + if commit and rows: + await session.commit() + return rows + @classmethod async def mark_read(cls, session: AsyncSession, record_id, *, user_id): row = await cls.get_by_id(session, record_id, user_id=user_id) diff --git a/backend/notifications/views.py b/backend/notifications/views.py index eef3bf6..ad0ee4a 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,10 +1,11 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession import httpx - +import logging import uuid from notifications.models import EmailConfirmationTokens,Notifications +from role.models import EnumRoles from notifications.plugins import ( CONFIRM_TOKEN_RESEND_SECONDS, CONFIRM_TOKEN_TTL_SECONDS, @@ -26,6 +27,67 @@ from notifications.serializers import ( ) from users.models import Users +logger = logging.getLogger(__name__) + +# Candidate-history event_type → in-app kind, title, and profile tab. +_HISTORY_KIND = { + "stage.changed": "application", + "candidate.created": "application", + "candidate.imported": "application", + "interview.created": "interview", + "interview.updated": "interview", + "calendar.created": "interview", + "calendar.rescheduled": "interview", + "calendar.cancelled": "interview", + "ats.scored": "assessment", + "form.created": "approval", + "form.updated": "approval", + "note.created": "message", + "note.updated": "message", + "feedback.created": "message", + "feedback.updated": "message", +} +_HISTORY_TITLE = { + "stage.changed": "Stage changed", + "note.created": "Note added", + "note.updated": "Note updated", + "feedback.created": "Feedback added", + "feedback.updated": "Feedback updated", + "interview.created": "Interview scheduled", + "interview.updated": "Interview updated", + "calendar.created": "Calendar event created", + "calendar.rescheduled": "Interview rescheduled", + "calendar.cancelled": "Interview cancelled", + "favorite.changed": "Favorite updated", + "rating.changed": "Rating updated", + "candidate.created": "Candidate added", + "candidate.imported": "Candidate imported", + "document.uploaded": "Document uploaded", + "ats.scored": "ATS score ready", + "form.created": "Form submitted", + "form.updated": "Form updated", +} +_HISTORY_TAB = { + "stage.changed": "History", + "interview.created": "Interview", + "interview.updated": "Interview", + "calendar.created": "Interview", + "calendar.rescheduled": "Interview", + "calendar.cancelled": "Interview", + "note.created": "Notes", + "note.updated": "Notes", + "feedback.created": "Activity", + "feedback.updated": "Activity", + "form.created": "Forms", + "form.updated": "Forms", + "ats.scored": "Resume", + "document.uploaded": "History", + "candidate.created": "History", + "candidate.imported": "History", + "favorite.changed": "History", + "rating.changed": "History", +} + class Confirmation: def __init__(self,session:AsyncSession): @@ -118,6 +180,315 @@ def _user_id(current_user): return uid +def _humanize_event(event_type): + text = str(event_type or "").replace(".", " ").replace("_", " ").strip() + return text[:1].upper() + text[1:] if text else "Update" + + +def _candidate_link(user_id, tab="History"): + path = f"/candidate/{user_id}" + if tab: + return f"{path}?tab={tab}" + return path + + +def _job_link(job_post_id, tab=None): + path = f"/jobs?job={job_post_id}" + if tab: + return f"{path}&tab={tab}" + return path + + +async def system_admin_ids(session): + return await Users.ids_by_role_names(session, [EnumRoles.SYSTEM_ADMINISTRATOR.value]) + + +async def job_recruiter_ids(session, job): + """Recruiters currently linked to the job post. + + Uses the live pointer (current_recruiter_id) and open job_assignments + rows with assignment_role=primary_recruiter. + """ + ids = set() + if job is None: + return ids + uid = _as_uuid(getattr(job, "current_recruiter_id", None)) + if uid is not None: + ids.add(uid) + from job.assignment.models import JobAssignments + rows = await JobAssignments.fetch_by_job( + session, job.id, current_only=True, assignment_role="primary_recruiter", + ) + for row in rows: + rid = _as_uuid(row.user_id) + if rid is not None: + ids.add(rid) + return ids + + +async def job_stakeholder_ids(session, jobs, *, extra_ids=None, include_admins=True): + """Recruiter, hiring manager, created_by, plus system admins. + + `jobs` may be one row or an iterable. Extra ids cover people who just + left an assignment so they still see the history entry. + """ + rows = jobs if isinstance(jobs, (list, tuple, set)) else [jobs] + ids = set() + for job in rows: + if job is None: + continue + ids.update(await job_recruiter_ids(session, job)) + for raw in (job.hiring_manager_id, job.created_by): + uid = _as_uuid(raw) + if uid is not None: + ids.add(uid) + for raw in extra_ids or []: + uid = _as_uuid(raw) + if uid is not None: + ids.add(uid) + if include_admins: + ids.update(await system_admin_ids(session)) + return ids + + +async def notify_users( + session, + user_ids, + *, + kind, + title, + body=None, + link_path=None, + inbox_id=None, + job_post_id=None, + exclude_ids=None, + commit=True, +): + """Fan-out one in-app row per recipient. Failures never raise.""" + try: + exclude = {_as_uuid(x) for x in (exclude_ids or [])} + exclude.discard(None) + seen = set() + payloads = [] + for raw in user_ids or []: + uid = _as_uuid(raw) + if uid is None or uid in exclude or uid in seen: + continue + seen.add(uid) + payloads.append({ + "user_id": uid, + "kind": kind, + "title": title, + "body": body, + "link_path": link_path, + "inbox_id": inbox_id, + "job_post_id": job_post_id, + }) + if not payloads: + return [] + return await Notifications.insert_many(session, payloads, commit=commit) + except Exception as exc: + logger.warning("notification insert skipped: %s", exc) + return [] + + +async def notify_job_stakeholders( + session, + job, + *, + kind, + title, + body=None, + link_path=None, + extra_ids=None, + exclude_ids=None, + commit=True, +): + if job is None: + return [] + recipients = await job_stakeholder_ids(session, job, extra_ids=extra_ids) + return await notify_users( + session, + recipients, + kind=kind, + title=title, + body=body, + link_path=link_path or _job_link(job.id), + job_post_id=job.id, + exclude_ids=exclude_ids, + commit=commit, + ) + + +async def notify_job_created(session, job, *, actor_id=None): + """New requisition: recruiter, hiring manager, created_by, system admins. + + created_by is included even when they are the actor — that is who asked. + """ + if job is None: + return [] + title = (job.title or "A job post").strip() or "A job post" + return await notify_job_stakeholders( + session, + job, + kind="approval", + title="New job post", + body=f"{title} was created", + link_path=_job_link(job.id), + exclude_ids=None, + commit=True, + ) + + +async def notify_job_status(session, job, *, from_status, to_status, actor_id=None): + """Status history: assigned recruiter, created_by, and every system admin.""" + if job is None: + return [] + title = (job.title or "A job post").strip() or "A job post" + if from_status: + body = f"{title} moved from {from_status} to {to_status}" + heading = "Requisition closed" if to_status == "closed" else "Requisition updated" + else: + body = f"{title} is now {to_status}" + heading = "Requisition updated" + recipients = await job_recruiter_ids(session, job) + created = _as_uuid(job.created_by) + if created is not None: + recipients.add(created) + recipients.update(await system_admin_ids(session)) + return await notify_users( + session, + recipients, + kind="approval", + title=heading, + body=body, + link_path=_job_link(job.id, tab="history"), + job_post_id=job.id, + exclude_ids=None, + commit=True, + ) + + +async def notify_job_assignment(session, job, *, role_label, actor_id=None, previous_ids=None): + if job is None: + return [] + title = (job.title or "A job post").strip() or "A job post" + return await notify_job_stakeholders( + session, + job, + kind="approval", + title="Job assignment updated", + body=f"{title}: {role_label} changed", + link_path=_job_link(job.id, tab="history"), + extra_ids=previous_ids, + exclude_ids=[actor_id] if actor_id else None, + commit=True, + ) + + +async def _job_ids_for_candidate_event(session, *, user_id, inbox_id, manual_upload_candidate_id): + """Resolve job posts for a history row without importing candidate views.""" + from inbox.models import Inbox + from job.candidate.models import Manual_UPLOAD_CANDIDATE + + ids = set() + if inbox_id is not None: + link = await Inbox.get_inbox_with_message(session, inbox_id) + msg = getattr(link, "messages", None) if link is not None else None + jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None + if jid: + ids.add(jid) + if manual_upload_candidate_id is not None: + manual = await Manual_UPLOAD_CANDIDATE.get_by_id(session, manual_upload_candidate_id) + if manual and manual.job_post_id: + ids.add(manual.job_post_id) + scoped = inbox_id is not None or manual_upload_candidate_id is not None + if ids or user_id is None or scoped: + return ids + rows = await Inbox.get_candidate_profile(session=session, user_id=user_id, limit=1000, offset=0) + records = rows if isinstance(rows, list) else ([rows] if rows else []) + for rec in records: + msg = getattr(rec, "messages", None) + jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None + if jid: + ids.add(jid) + manual = await Manual_UPLOAD_CANDIDATE.get_by_user_id(session, user_id) + if manual and manual.job_post_id: + ids.add(manual.job_post_id) + return ids + + +async def notify_candidate_history( + session, + row, + *, + inbox_id=None, + manual_upload_candidate_id=None, + commit=True, +): + """One in-app row per job stakeholder for a candidate_history write.""" + if row is None: + return [] + try: + from job.job_post.models import JobPosts + + event_type = row.event_type + names = await Users.names_by_ids(session, [row.user_id]) + candidate_name = names.get(str(row.user_id)) or "A candidate" + heading = _HISTORY_TITLE.get(event_type) or _humanize_event(event_type) + kind = _HISTORY_KIND.get(event_type, "system") + tab = _HISTORY_TAB.get(event_type, "History") + if row.description: + detail = row.description + elif row.from_value and row.to_value: + detail = f"{row.from_value} → {row.to_value}" + elif row.to_value: + detail = str(row.to_value) + else: + detail = heading + body = f"{candidate_name}: {detail}" + link_path = _candidate_link(row.user_id, tab=tab) + + job_ids = await _job_ids_for_candidate_event( + session, + user_id=row.user_id, + inbox_id=inbox_id if inbox_id is not None else row.inbox_id, + manual_upload_candidate_id=( + manual_upload_candidate_id + if manual_upload_candidate_id is not None + else row.manual_upload_candidate_id + ), + ) + jobs = [] + if job_ids: + jobs = await JobPosts.get_by_ids(session, list(job_ids), active_only=False) + if jobs: + recipients = await job_stakeholder_ids(session, jobs, include_admins=True) + else: + recipients = set(await system_admin_ids(session)) + + exclude = [row.user_id] + if row.actor_id: + exclude.append(row.actor_id) + job_post_id = jobs[0].id if jobs else None + inbox_value = row.inbox_id if row.inbox_id is not None else inbox_id + return await notify_users( + session, + recipients, + kind=kind, + title=heading, + body=body, + link_path=link_path, + inbox_id=inbox_value, + job_post_id=job_post_id, + exclude_ids=exclude, + commit=commit, + ) + except Exception as exc: + logger.warning("notification insert skipped: %s", exc) + return [] + + class Notification: def __init__(self,session:AsyncSession): self.session=session diff --git a/backend/users/models.py b/backend/users/models.py index 34d9082..323f394 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -135,6 +135,23 @@ class Users(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()) + @classmethod + async def ids_by_role_names(cls, session: AsyncSession, role_names): + """Non-deleted user ids whose Roles.role_name is in `role_names`.""" + names = sorted({(n or "").strip() for n in (role_names or []) if (n or "").strip()}) + if not names: + return [] + lowers = [n.lower() for n in names] + result = await session.execute( + select(cls.id) + .join(Roles, cls.role_id == Roles.id) + .where( + func.lower(Roles.role_name).in_(lowers), + cls.is_deleted == False, # noqa: E712 + ) + ) + return [row[0] for row in result.all()] + @classmethod async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: """Resolve {user_id: name} in a single query. diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx index 89807cf..27bc55b 100644 --- a/frontend/src/app/Topbar.jsx +++ b/frontend/src/app/Topbar.jsx @@ -35,7 +35,12 @@ export default function Topbar({ onOpenNav, searchRef }) { const navigate = useNavigate() const qc = useQueryClient() - const notifQuery = useQuery({ queryKey: qk.notifications.list({ top: 6 }), queryFn: fetchNotifications }) + const notifQuery = useQuery({ + queryKey: qk.notifications.list({ top: 6 }), + queryFn: fetchNotifications, + staleTime: 0, + refetchInterval: 15_000, + }) const notifications = notifQuery.data?.items ?? [] const unread = notifQuery.data?.unread ?? 0 diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index ba517d5..976f09e 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -149,6 +149,8 @@ export function useBadges() { return 0 } }, + staleTime: 0, + refetchInterval: 15_000, }) return { diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 42eaa32..6c8357d 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -1,5 +1,6 @@  import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' @@ -28,6 +29,12 @@ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', ' // Forward progression for the live Advance button. Rejected has no next stage. const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } + +function tabFromSearch(tabParam, visibleTabs, fallback) { + if (!tabParam) return fallback + const wanted = String(tabParam).trim() + return visibleTabs.find((t) => t.toLowerCase() === wanted.toLowerCase()) || fallback +} const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round'] const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show'] const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note'] @@ -107,7 +114,12 @@ export default function CandidateProfile({ const { can, user } = useAuth() const isManager = isHiringManager(user) const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS - const [tab, setTab] = useState(isManager ? 'Forms' : 'Overview') + const [searchParams] = useSearchParams() + const [tab, setTab] = useState(() => tabFromSearch( + variant === 'page' ? searchParams.get('tab') : null, + visibleTabs, + isManager ? 'Forms' : 'Overview', + )) const { data: interviews = [] } = useQuery(seedQuery('interviews')) const isLive = Boolean(c.userId) diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 44fea39..fad60bb 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -8,7 +8,7 @@ ============================================================ */ import { useEffect, useMemo, useRef, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import AiFieldAssist from '../ui/AiFieldAssist' @@ -83,6 +83,7 @@ export default function Jobs() { const { can } = useAuth() const navigate = useNavigate() const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() const qc = useQueryClient() const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) @@ -106,26 +107,43 @@ export default function Jobs() { const [type, setType] = useState('') const [viewing, setViewing] = useState(null) + const [viewingTab, setViewingTab] = useState('details') const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const canEdit = can('jobs.edit') const canDelete = can('jobs.delete') - // Deep-link intents from global search, the dashboard and the manager portal. - // Consume once and replace history state: jobs refetch after a status PATCH - // used to replay openCreate and pop the create modal over the detail view. + // Deep-link intents from notifications, global search, the dashboard and the + // manager portal. Consume once and replace history: jobs refetch after a + // status PATCH used to replay openCreate and pop the create modal over the + // detail view. `/jobs?job=` / `?tab=history` is the notification target. useEffect(() => { const st = location.state - if (!st?.openCreate && !st?.openJob) return - if (st.openCreate) setCreating(true) - if (st.openJob) { - const job = jobs.find((j) => j.id === st.openJob) - if (job) setViewing(job) - else if (!jobsQuery.isSuccess) return + const jobId = searchParams.get('job') || st?.openJob + const tab = String(searchParams.get('tab') || '').toLowerCase() + if (!st?.openCreate && !jobId) return + if (st?.openCreate) setCreating(true) + if (jobId) { + const job = jobs.find((j) => j.id === jobId) + if (job) { + setViewing(job) + setViewingTab(tab === 'history' ? 'history' : 'details') + } else if (!jobsQuery.isSuccess) return } - navigate('.', { replace: true, state: null }) - }, [location.state, jobs, jobsQuery.isSuccess, navigate]) + const next = new URLSearchParams(searchParams) + let queryChanged = false + if (next.has('job')) { + next.delete('job') + queryChanged = true + } + if (next.has('tab')) { + next.delete('tab') + queryChanged = true + } + if (queryChanged) setSearchParams(next, { replace: true }) + if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null }) + }, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams]) useEffect(() => { if (!viewing) return @@ -151,6 +169,7 @@ export default function Jobs() { onSuccess: (res) => { qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.requisitions.all() }) + qc.invalidateQueries({ queryKey: qk.notifications.all() }) setCreating(false) if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error') else toast('Job created', 'success') @@ -184,6 +203,7 @@ export default function Jobs() { mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: qk.jobs.all() }) + qc.invalidateQueries({ queryKey: qk.notifications.all() }) toast(`Status set to ${vars.status}`, 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'), @@ -368,12 +388,14 @@ export default function Jobs() { {viewing && ( setViewing(null)} + onClose={() => { setViewing(null); setViewingTab('details') }} onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} onEdit={() => { setEditing(viewing); setViewing(null) }} onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })} @@ -1320,10 +1342,10 @@ function JobCover({ jobId }) { } function JobDetail({ - job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, + job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, statusLabels = jobsApi.JOB_STATUSES, }) { - const [tab, setTab] = useState('details') + const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details') const historyQuery = useQuery({ queryKey: qk.assignments.job(j.id), queryFn: async () => {