From 17f6068dd876a56f11a4e7eb1542ec2d93fa91eb Mon Sep 17 00:00:00 2001 From: JoachimLK Date: Tue, 11 Aug 2026 09:20:34 +0200 Subject: [PATCH 1/9] feat(dashboard): surface what actually needs attention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "To Review" tile's `new`-stage count with per-user read receipts, and add the two things the dashboard was silent about. * "To Review" now counts applicants the signed-in user has never opened, matching the per-job badges on /jobs. The `new` stage stayed high long after every application in it had been read, so the tile could not be worked down to zero. Clicking through opens the list filtered to the same set (`?viewed=unviewed`), which the applications API and a new Viewed filter on the list now support, so the number and the rows behind it cannot disagree. * Unanswered candidate replies: threads whose newest message is inbound. Derived from message order rather than `candidateConversation.unread Count`, because nothing in the product clears that counter — a badge built on it could only ever grow. "Newest message is inbound" clears itself the moment anything goes out on the thread. * Application detail tabs are addressable (`?tab=inbox`), validated against a shared list so a hand-edited value cannot land the component in a state with no content. The reply card links straight into the thread rather than the overview. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + app/components/ApplicationDetail.vue | 13 +- app/components/ApplicationsList.vue | 75 +++++++++- app/composables/useApplications.ts | 3 + app/composables/useDashboard.ts | 15 +- app/pages/dashboard/applications/[id].vue | 6 +- app/pages/dashboard/index.vue | 160 ++++++++++++++------- server/api/applications/index.get.ts | 19 ++- server/api/dashboard/stats.get.ts | 26 +++- server/utils/applicationViews.ts | 52 +++++-- server/utils/schemas/application.ts | 2 + server/utils/unansweredReplies.ts | 108 ++++++++++++++ shared/application-detail-tabs.ts | 27 ++++ tests/unit/application-detail-tabs.test.ts | 29 ++++ tests/unit/application-views.test.ts | 20 +++ 15 files changed, 475 insertions(+), 81 deletions(-) create mode 100644 server/utils/unansweredReplies.ts create mode 100644 shared/application-detail-tabs.ts create mode 100644 tests/unit/application-detail-tabs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29889425..e5eb210f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add ### Changed +* **dashboard:** the "To Review" tile now counts applicants you haven't opened, matching the read receipts already used by the jobs list, instead of the size of the `new` stage — a stage that stayed high long after every application in it had been read. Clicking it opens the candidates list filtered to the same set (`?viewed=unviewed`), so the number and the rows behind it can no longer disagree. The applications list gained a matching **Viewed** filter (any / not viewed by me / viewed by me), which is saveable as part of a view. * **billing:** make bring-your-own-AI-key (BYOK) a Solo-and-above capability instead of a free-plan feature — a free org that brought its own key got the uncapped assistant and uncapped shortlists for nothing, which is what the entry plan sells. Only *creating* a config is gated: free orgs that configured a key before this change keep editing and using it, and grandfathered orgs are unaffected (BYOK is their only route to AI at all). * **licensing:** re-open Reqcore as open-core — AGPLv3 for the core app, with a new [`ee/`](ee) directory (Nuxt layer) for paid, cloud-only features under a separate commercial license. Self-hosting is supported again, best-effort and unsupported (see [SELF-HOSTING.md](SELF-HOSTING.md)). Moved the already plan-gated SSO (`ee/server/api/sso/`), org-wide audit log (`ee/server/api/activity-log/index.get.ts`), source-analytics (`ee/server/api/source-tracking/stats.get.ts`), and AI-analysis dashboard (`ee/server/api/ai-analysis/stats.get.ts`) endpoints out of the AGPL tree and into `ee/` so the license split matches what's actually gated; the underlying tables and the ungated candidate timeline/activity feed/tracking-link CRUD stay in core. diff --git a/app/components/ApplicationDetail.vue b/app/components/ApplicationDetail.vue index 64c89e5a..e9c51405 100644 --- a/app/components/ApplicationDetail.vue +++ b/app/components/ApplicationDetail.vue @@ -10,12 +10,16 @@ import type { Component } from 'vue' import type { Interview } from '~/composables/useInterviews' import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly' import { APPLICATION_STATUS_TRANSITIONS } from '~~/shared/status-transitions' +import type { ApplicationDetailTab } from '~~/shared/application-detail-tabs' const props = withDefaults(defineProps<{ applicationId: string variant?: 'page' | 'drawer' + /** Tab to open on. Lets deep links land on a tab (e.g. `?tab=inbox`). */ + initialTab?: ApplicationDetailTab }>(), { variant: 'page', + initialTab: 'overview', }) const emit = defineEmits<{ @@ -45,8 +49,8 @@ useSeoMeta({ // Tabs & Overview section toggles // ───────────────────────────────────────────── -type DetailTab = 'overview' | 'inbox' | 'cover-letter' | 'interviews' | 'documents' | 'responses' | 'ai-analysis' | 'timeline' | 'properties' | 'notes' -const detailTab = ref('overview') +type DetailTab = ApplicationDetailTab +const detailTab = ref(props.initialTab) // Toggle the page between centered (max-w-4xl) and full width const isWideDetail = ref(false) @@ -636,7 +640,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeyNavigation) // ───────────────────────────────────────────── watch(() => props.applicationId, () => { - detailTab.value = 'overview' + // Back to the requested tab, not a hardcoded 'overview': navigating between + // two `?tab=inbox` deep links keeps the component mounted, and resetting to + // overview there would contradict the URL. + detailTab.value = props.initialTab isWideDetail.value = false expandedInterviewId.value = null showOverviewDropdown.value = false diff --git a/app/components/ApplicationsList.vue b/app/components/ApplicationsList.vue index 0fb88ac4..307b9434 100644 --- a/app/components/ApplicationsList.vue +++ b/app/components/ApplicationsList.vue @@ -147,6 +147,35 @@ watch(activeStatus, (newStatus) => { }) const statusFilter = computed(() => activeStatus.value) + +// ── Viewed filter ───────────────────────────────────────────────────────────── +// Server-side read receipts, per signed-in user. The dashboard's "To Review" +// tile links here with `?viewed=unviewed`, so the count it shows and the rows +// listed here come from the same definition. + +const VIEWED_OPTIONS = ['unviewed', 'viewed'] as const +type ViewedFilter = typeof VIEWED_OPTIONS[number] + +const initialViewed = VIEWED_OPTIONS.includes(route.query.viewed as any) + ? (route.query.viewed as ViewedFilter) + : undefined +const activeViewed = useState(`app-filter-viewed${scopeSuffix}`, () => initialViewed) +if (initialViewed !== undefined) { + activeViewed.value = initialViewed +} + +watch(activeViewed, (next) => { + const query = { ...route.query } + if (next) { + query.viewed = next + } + else { + delete query.viewed + } + router.replace({ query }) +}) + +const viewedFilter = computed(() => activeViewed.value) const propertyFilters = ref([]) const jobIdFilter = computed(() => props.jobId) @@ -154,11 +183,12 @@ const { applications, total, fetchStatus, error, refresh } = useApplications({ page, limit: pageSize, status: statusFilter, + viewed: viewedFilter, propertyFilters, jobId: jobIdFilter, }) -watch([statusFilter, propertyFilters, pageSize], () => { +watch([statusFilter, viewedFilter, propertyFilters, pageSize], () => { page.value = 1 }, { deep: true }) @@ -251,11 +281,12 @@ const filteredApplications = computed(() => { }) const hasActiveFilters = computed(() => - activeStatus.value != null || activeJobId.value != null || debouncedSearch.value.length > 0 || propertyFilters.value.length > 0, + activeStatus.value != null || activeViewed.value != null || activeJobId.value != null || debouncedSearch.value.length > 0 || propertyFilters.value.length > 0, ) function clearAllFilters() { activeStatus.value = undefined + activeViewed.value = undefined activeJobId.value = undefined searchInput.value = '' debouncedSearch.value = '' @@ -312,6 +343,7 @@ const statusLabels: Record = { type ApplicationsViewSettings = { status?: Status + viewed?: ViewedFilter jobId?: string propertyFilters: import('~~/shared/properties').PropertyFilter[] sortKey: SortKey @@ -321,6 +353,7 @@ type ApplicationsViewSettings = { const defaultSettings: ApplicationsViewSettings = { status: undefined, + viewed: undefined, jobId: undefined, propertyFilters: [], sortKey: 'created', @@ -335,6 +368,7 @@ const isFullscreen = ref(false) const isWideDetail = ref(!!props.jobId) const currentSettings = computed(() => ({ status: activeStatus.value, + viewed: activeViewed.value, jobId: activeJobId.value, propertyFilters: [...propertyFilters.value], sortKey: sortKey.value, @@ -344,6 +378,7 @@ const currentSettings = computed(() => ({ function applySettings(s: ApplicationsViewSettings) { activeStatus.value = s.status + activeViewed.value = s.viewed activeJobId.value = s.jobId propertyFilters.value = [...(s.propertyFilters ?? [])] sortKey.value = s.sortKey @@ -406,7 +441,7 @@ function onUpdateView(id: string) { } const drawerActiveCount = computed(() => - [activeStatus.value, activeJobId.value].filter(Boolean).length + propertyFilters.value.length, + [activeStatus.value, activeViewed.value, activeJobId.value].filter(Boolean).length + propertyFilters.value.length, ) // ── Property value lookup helper ────────────────────────────────────────────── @@ -543,6 +578,40 @@ async function handleApplicationDeleted() { + +
+ +
+ + + +
+

+ Rejected applicants are excluded — they've already been decided on. +

+
+
diff --git a/app/composables/useApplications.ts b/app/composables/useApplications.ts index 4a5e6e5e..8595e85c 100644 --- a/app/composables/useApplications.ts +++ b/app/composables/useApplications.ts @@ -12,6 +12,8 @@ export function useApplications(options?: { jobId?: Ref | string candidateId?: Ref | string status?: Ref | string + /** 'unviewed' | 'viewed' — read receipts for the signed-in user */ + viewed?: Ref | string propertyFilters?: Ref | PropertyFilter[] }) { const { handlePreviewReadOnlyError } = usePreviewReadOnly() @@ -24,6 +26,7 @@ export function useApplications(options?: { ...(toValue(options?.jobId) && { jobId: toValue(options?.jobId) }), ...(toValue(options?.candidateId) && { candidateId: toValue(options?.candidateId) }), ...(toValue(options?.status) && { status: toValue(options?.status) }), + ...(toValue(options?.viewed) && { viewed: toValue(options?.viewed) }), ...(pf && pf.length > 0 && { propertyFilters: JSON.stringify(pf) }), } }) diff --git a/app/composables/useDashboard.ts b/app/composables/useDashboard.ts index 6aae5fab..a30c19ec 100644 --- a/app/composables/useDashboard.ts +++ b/app/composables/useDashboard.ts @@ -9,12 +9,16 @@ export function useDashboard() { headers: useRequestHeaders(['cookie']), }) - /** Summary counts (open jobs, candidates, applications, unreviewed) */ + /** + * Summary counts. `unviewedApplications` is per signed-in user — applicants + * this person has never opened, not the size of the `new` stage. + */ const counts = computed(() => data.value?.counts ?? { openJobs: 0, totalCandidates: 0, totalApplications: 0, - newApplications: 0, + unviewedApplications: 0, + unansweredReplies: 0, }) /** Application count per status */ @@ -41,12 +45,19 @@ export function useDashboard() { /** Top 5 open jobs sorted by application count */ const topJobs = computed(() => data.value?.topJobs ?? []) + /** + * Up to 5 candidate threads whose newest message is inbound — replies nobody + * has answered yet. `counts.unansweredReplies` is the untruncated total. + */ + const unansweredReplies = computed(() => data.value?.unansweredReplies ?? []) + return { counts, pipeline, jobsByStatus, recentApplications, topJobs, + unansweredReplies, fetchStatus, error, refresh, diff --git a/app/pages/dashboard/applications/[id].vue b/app/pages/dashboard/applications/[id].vue index d99698ee..f51c2d9f 100644 --- a/app/pages/dashboard/applications/[id].vue +++ b/app/pages/dashboard/applications/[id].vue @@ -1,5 +1,6 @@