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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ NOTIFICATIONS_ENABLED=true
# RESEND_WEBHOOK_SECRET below. External schedulers can trigger the worker at
# /api/admin/notification-dispatch using CRON_SECRET (as above).

# ─── Lifecycle (activation) emails ──────────────────────────────────────────
# Emits funnel events (org.created, job.posted) to Resend Automations, which
# owns the waiting, branching, and copy. Distinct from the recruiter
# notifications above: those report workspace activity, these chase an absence
# ("signed up, never opened a role").
#
# Fail-CLOSED — leave unset when self-hosting. Enabling it sends your users'
# email addresses to the Resend account behind RESEND_API_KEY, and the
# automations themselves live in that account's dashboard, not in this repo.
# Requires RESEND_API_KEY.
# LIFECYCLE_EMAILS_ENABLED

# ─── SEO ─────────────────────────────────────────────────────────────────────
# Used by @nuxtjs/seo for sitemaps, canonical URLs, and OG tags
NUXT_PUBLIC_SITE_URL=http://localhost:3000
Expand Down
8 changes: 8 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
# Only fail npm audit on high/critical severity vulnerabilities.
audit-level=high

# npm resolves peer dependencies differently than every lockfile this project
# has ever produced: the lock was generated on machines with
# legacy-peer-deps=true in ~/.npmrc, so CI (which has no user npmrc) rejected
# it with "Missing: oxc-parser@0.144.0 from lock file" — an optional peer of
# unctx that legacy resolution skips. Setting it here makes the resolution
# mode a property of the repo rather than of whoever last ran npm install.
legacy-peer-deps=true
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -18,6 +19,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add
* **applications:** track which applicants each recruiter has actually opened. Opening an application's detail — from the pipeline, the candidates table drawer, or the full page — writes a per-user read receipt (`application_view`), and unopened applicants are marked with a dot in the pipeline sidebar and the candidates table. The jobs list now says "N applicants you haven't viewed" instead of counting the `new` stage, which stayed high long after every application had been read; the same count drives the "needs your attention" grouping and card highlight. Applications that existed before this change are backfilled as viewed for current members, so the badge starts from a clean slate.

* **assistant:** ship the AI assistant on every plan with no rollout flag. It runs on the platform OpenRouter key by default — so an org gets a working assistant without configuring anything — or on the org's own key (BYOK), which is never budget-capped. Free orgs get a lifetime allowance of 20 assistant messages (`AI_FREE_PLAN_CHATBOT_TURN_LIMIT`), metered separately from the free AI-shortlist allowance and surfaced as a meter on the billing page; paid orgs are metered by the monthly AI budget instead. Platform-paid turns are recorded in a new `ai_usage_event` ledger and count against the org's monthly AI budget and the global daily kill-switch alongside analysis runs. Grandfathered orgs stay BYOK-only.
* **lifecycle emails:** emit activation-funnel events (`org.created`, `job.posted`) to Resend Automations, which owns the waiting, branching, and copy for the "signed up, never opened a role" nudge. Distinct from the recruiter notification outbox: that engine reports workspace activity, this one chases an absence, and an absence has no moment to enqueue at. Off by default (`LIFECYCLE_EMAILS_ENABLED`) — enabling it sends user email addresses to the Resend account behind `RESEND_API_KEY`, so self-hosters keep it unset. Emits are fire-and-forget and skip demo workspaces and reserved (`@example.com`) addresses.
* **GDPR retention:** add a shared candidate-retention runner with a daily Nitro task, external cron endpoint, instance-wide emergency switch, quarantine restoration on renewed public engagement, localized administration UI, and computed expiry visibility.
* **GDPR erasure:** remove application-linked comments, custom properties, and activity records in addition to the candidate database graph and S3 objects.
* **blog:** add Cluster 8 career page articles — pillar (career-page-that-converts) and two supporting articles (career-page-seo, google-for-jobs-structured-data)
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
FROM node:22.22-alpine AS builder
WORKDIR /app

# Install dependencies first (layer-cached unless package.json changes)
COPY package*.json ./
# Install dependencies first (layer-cached unless package.json changes).
# .npmrc comes along because it carries the peer-resolution mode the lockfile
# was generated under — without it `npm ci` here rejects the lock outright.
COPY package*.json .npmrc ./
RUN npm ci

# Copy source and build
Expand Down
49 changes: 48 additions & 1 deletion app/components/ApplicationBuilderPreview.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Briefcase, MapPin, Monitor, Smartphone } from 'lucide-vue-next'
import { Banknote, Briefcase, MapPin, Monitor, Smartphone } from 'lucide-vue-next'

type Question = {
id: string
Expand All @@ -24,6 +24,11 @@ type JobDetails = {
type?: string
experienceLevel?: string
remoteStatus?: string
salaryMin?: number | null
salaryMax?: number | null
salaryCurrency?: string | null
salaryUnit?: string | null
salaryNegotiable?: boolean
}

const props = defineProps<{
Expand Down Expand Up @@ -70,6 +75,47 @@ const previewJob = computed(() => ({
questions: props.applicationForm.questions,
}))

const unitSuffixes: Record<string, string> = {
YEAR: '/yr',
MONTH: '/mo',
HOUR: '/hr',
}

/**
* The pay chip as a candidate sees it, so what the recruiter types on step 1
* has somewhere to land in the preview beside it. Deliberately forgiving: this
* renders a half-filled range while it is still being typed, where the public
* listing only ever sees a finished one.
*/
const salaryLabel = computed(() => {
const details = props.jobDetails
if (!details) return null
if (details.salaryNegotiable) return 'Negotiable'

const { salaryMin, salaryMax, salaryCurrency, salaryUnit } = details
if (salaryMin == null && salaryMax == null) return null

// The code is free text on the way in, so an unfinished or invented one
// ("NO", "ZZZ") reaches this mid-keystroke. `Intl` throws on those — falling
// back to a plain number keeps the preview rendering while it is typed.
const format = (value: number) => {
if (salaryCurrency) {
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: salaryCurrency, maximumFractionDigits: 0 }).format(value)
} catch { /* not a currency code yet */ }
}
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
}

const suffix = salaryUnit ? unitSuffixes[salaryUnit] ?? '' : ''
const range = salaryMin != null && salaryMax != null
? `${format(salaryMin)} – ${format(salaryMax)}`
: salaryMin != null
? `From ${format(salaryMin)}`
: `Up to ${format(salaryMax!)}`
return `${range}${suffix}`
})

const metadata = computed(() => {
const details = props.jobDetails
if (!details) return []
Expand All @@ -79,6 +125,7 @@ const metadata = computed(() => {
details.location ? { label: details.location, icon: MapPin } : null,
details.remoteStatus ? { label: workplaceLabels[details.remoteStatus] ?? details.remoteStatus, icon: MapPin } : null,
details.experienceLevel ? { label: experienceLabels[details.experienceLevel] ?? details.experienceLevel, icon: Briefcase } : null,
salaryLabel.value ? { label: salaryLabel.value, icon: Banknote } : null,
].filter((item): item is { label: string; icon: typeof Briefcase } => item !== null)
})
</script>
Expand Down
13 changes: 10 additions & 3 deletions app/components/ApplicationDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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<DetailTab>('overview')
type DetailTab = ApplicationDetailTab
const detailTab = ref<DetailTab>(props.initialTab)
Comment on lines +52 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

ast-grep outline 'app/pages/dashboard/applications/[id].vue' --items all
rg -n -C 5 'ApplicationDetail|initialTab|parseApplicationDetailTab|route\.query\.tab' \
  'app/pages/dashboard/applications/[id].vue'

Repository: reqcore-inc/reqcore

Length of output: 1488


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

fd -i 'ApplicationDetail.vue' .
fd -i 'application-detail-tabs' .
printf '\n--- component outline ---\n'
ast-grep outline "$(fd -i -t f 'ApplicationDetail.vue' . | head -n 1)" --items all
printf '\n--- relevant component references ---\n'
component="$(fd -i -t f 'ApplicationDetail.vue' . | head -n 1)"
rg -n -C 8 'initialTab|detailTab|applicationId|watch\(' "$component"
printf '\n--- tab parser and usages ---\n'
tabfile="$(fd -i -t f 'application-detail-tabs' . | head -n 1)"
cat -n "$tabfile"
rg -n -C 5 'initial-tab|initialTab|route\.query\.tab|parseApplicationDetailTab' app shared 2>/dev/null || true

Repository: reqcore-inc/reqcore

Length of output: 32309


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

page = Path("app/pages/dashboard/applications/[id].vue").read_text()
component = Path("app/components/ApplicationDetail.vue").read_text()

assert "const initialTab = computed(() => parseApplicationDetailTab(route.query.tab))" in page
assert ':initial-tab="initialTab"' in page
assert "const detailTab = ref<DetailTab>(props.initialTab)" in component

watchers = re.findall(
    r"watch\(\s*(?:\(\)\s*=>\s*)?([^,\n]+),",
    component,
)
assert any("props.applicationId" in watcher for watcher in watchers)
assert not any("props.initialTab" in watcher for watcher in watchers)

print("page initialTab is derived from route.query.tab and passed as a prop")
print("detailTab is initialized from props.initialTab")
print("the existing reset watcher observes props.applicationId")
print("no watcher observes props.initialTab")
print("same-application query changes can therefore leave detailTab unchanged")
PY

Repository: reqcore-inc/reqcore

Length of output: 437


Synchronize detailTab with initialTab.

When same-application navigation changes route.query.tab, initialTab updates but detailTab does not. The watcher at Lines 642-646 observes only props.applicationId. Add a watcher for props.initialTab.

Proposed fix
 const detailTab = ref<DetailTab>(props.initialTab)
+
+watch(() => props.initialTab, (tab) => {
+  detailTab.value = tab
+})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type DetailTab = ApplicationDetailTab
const detailTab = ref<DetailTab>(props.initialTab)
type DetailTab = ApplicationDetailTab
const detailTab = ref<DetailTab>(props.initialTab)
watch(() => props.initialTab, (tab) => {
detailTab.value = tab
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/ApplicationDetail.vue` around lines 52 - 53, Update
ApplicationDetail’s reactive state so detailTab stays synchronized with changes
to props.initialTab, including same-application route query navigation. Add a
watcher for props.initialTab alongside the existing applicationId watcher and
assign the new value to detailTab.


// Toggle the page between centered (max-w-4xl) and full width
const isWideDetail = ref(false)
Expand Down Expand Up @@ -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
Expand Down
75 changes: 72 additions & 3 deletions app/components/ApplicationsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,48 @@ 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<ViewedFilter | undefined>(`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 })
})
Comment on lines +159 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 5 'activeStatus|activeViewed|route\.query\.(status|viewed)|router\.replace|clearAllFilters|applySettings' \
  app/components/ApplicationsList.vue

Repository: reqcore-inc/reqcore

Length of output: 9982


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- component imports and filter setup ---'
sed -n '1,190p' app/components/ApplicationsList.vue

printf '%s\n' '--- settings and view application logic ---'
sed -n '350,445p' app/components/ApplicationsList.vue

printf '%s\n' '--- route/filter references in the component ---'
rg -n -C 4 'useRoute|useRouter|route\.query|watch\(|useState|clearAllFilters|applySettings' app/components/ApplicationsList.vue

Repository: reqcore-inc/reqcore

Length of output: 15095


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- dependency versions ---'
rg -n '"(vue|vue-router|nuxt)"' package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || true

printf '%s\n' '--- deterministic watcher/query model ---'
python3 - <<'PY'
from copy import deepcopy

def status_write(route_query, status):
    query = deepcopy(route_query)
    if status:
        query["status"] = status
    else:
        query.pop("status", None)
    return query

def viewed_write(route_query, viewed):
    query = deepcopy(route_query)
    if viewed:
        query["viewed"] = viewed
    else:
        query.pop("viewed", None)
    return query

cases = [
    ("both added", {}, "new", "unviewed"),
    ("both changed", {"status": "old", "viewed": "viewed"}, "new", "unviewed"),
    ("both cleared", {"status": "new", "viewed": "unviewed"}, None, None),
]
for name, route, status, viewed in cases:
    first = status_write(route, status)
    second = viewed_write(route, viewed)
    print(name)
    print("  status watcher submits:", first)
    print("  viewed watcher submits:", second)
    print("  last submitted query:", second)
PY

Repository: reqcore-inc/reqcore

Length of output: 2274


🌐 Web query:

Vue Router 4 router.replace returns Promise navigation asynchronous current route update documentation

💡 Result:

In Vue Router 4, the router.replace method (like router.push) returns a Promise that allows you to wait until the navigation is fully finished [1][2]. Key technical details regarding this Promise: 1. Asynchronous Resolution: The Promise resolves once the navigation successfully completes or fails [3]. Navigation includes the processing of all asynchronous navigation guards, such as those that resolve async route components or perform data fetching [4]. 2. Success vs. Failure: To handle navigation failures (e.g., cancelling a navigation via next(false)), the Promise resolves to either undefined (on success) or a NavigationFailure object (if the navigation was aborted or cancelled) [3]. 3. Rejection: The Promise only rejects if an unhandled error occurs during the navigation process (e.g., an error inside a navigation guard) [3]. In such cases, the error will also be triggered through the router.onError hook [3]. Because the Promise resolves rather than rejects when a navigation is simply cancelled, you can await the call safely without triggering "Uncaught (in promise)" errors for expected navigation failures [3]. By checking the returned value or catching errors specifically, you can differentiate between successful, cancelled, and failed navigations [3].

Citations:


Synchronize status and viewed filters in one route-query update.

Watch route changes and update both activeStatus and activeViewed, including undefined. Replace the two independent router.replace watchers with one watcher that writes both query parameters. Separate updates can submit stale route.query snapshots and discard the other filter change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/ApplicationsList.vue` around lines 159 - 176, Replace the
independent filter watchers around activeStatus and activeViewed with one
watcher that observes both state values and route changes, then updates
query.status and query.viewed together, including removing either key when its
value is undefined. Ensure the combined router.replace uses the latest route
query so changing one filter does not overwrite the other.


const viewedFilter = computed(() => activeViewed.value)
const propertyFilters = ref<import('~~/shared/properties').PropertyFilter[]>([])
const jobIdFilter = computed(() => props.jobId)

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 })

Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -312,6 +343,7 @@ const statusLabels: Record<Status, string> = {

type ApplicationsViewSettings = {
status?: Status
viewed?: ViewedFilter
jobId?: string
propertyFilters: import('~~/shared/properties').PropertyFilter[]
sortKey: SortKey
Expand All @@ -321,6 +353,7 @@ type ApplicationsViewSettings = {

const defaultSettings: ApplicationsViewSettings = {
status: undefined,
viewed: undefined,
jobId: undefined,
propertyFilters: [],
sortKey: 'created',
Expand All @@ -335,6 +368,7 @@ const isFullscreen = ref(false)
const isWideDetail = ref(!!props.jobId)
const currentSettings = computed<ApplicationsViewSettings>(() => ({
status: activeStatus.value,
viewed: activeViewed.value,
jobId: activeJobId.value,
propertyFilters: [...propertyFilters.value],
sortKey: sortKey.value,
Expand All @@ -344,6 +378,7 @@ const currentSettings = computed<ApplicationsViewSettings>(() => ({

function applySettings(s: ApplicationsViewSettings) {
activeStatus.value = s.status
activeViewed.value = s.viewed
activeJobId.value = s.jobId
propertyFilters.value = [...(s.propertyFilters ?? [])]
sortKey.value = s.sortKey
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -543,6 +578,40 @@ async function handleApplicationDeleted() {
</div>
</div>

<!-- Viewed -->
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-surface-500 dark:text-surface-400 mb-2">Viewed</label>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-full px-3 py-1.5 text-xs font-medium transition-colors"
:class="!activeViewed
? 'bg-surface-900 text-white dark:bg-surface-100 dark:text-surface-900'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700'"
@click="activeViewed = undefined"
>Any</button>
<button
type="button"
class="rounded-full px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeViewed === 'unviewed'
? 'bg-surface-900 text-white dark:bg-surface-100 dark:text-surface-900'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700'"
@click="activeViewed = activeViewed === 'unviewed' ? undefined : 'unviewed'"
>Not viewed by me</button>
<button
type="button"
class="rounded-full px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeViewed === 'viewed'
? 'bg-surface-900 text-white dark:bg-surface-100 dark:text-surface-900'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700'"
@click="activeViewed = activeViewed === 'viewed' ? undefined : 'viewed'"
>Viewed by me</button>
</div>
<p v-if="activeViewed === 'unviewed'" class="mt-2 text-xs text-surface-400 dark:text-surface-500">
Rejected applicants are excluded — they've already been decided on.
</p>
</div>

<!-- Job (global list only) -->
<div v-if="!jobId">
<label class="block text-xs font-semibold uppercase tracking-wide text-surface-500 dark:text-surface-400 mb-2">Job</label>
Expand Down
Loading
Loading