From 9937c3c27ee7df9469411723cf97795f2927ea4b Mon Sep 17 00:00:00 2001 From: os-sam Date: Mon, 7 Sep 2026 00:53:41 +0000 Subject: [PATCH] feat(views): employer pipeline kanban, inboxes, interview calendar, talent pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card 07 (#2). Four defineView containers under src/views/ and the `views:` key in objectstack.config.ts: - ats_application: default grid, `pipeline` kanban grouped by `stage`, five `inbox_*` grids filtered by stage, a two-column `default` form. - ats_interview: default grid and a `calendar` on `scheduled_at`, titled by the stored `display_name` mirror and coloured by `status`. - ats_candidate: `talent_pool` grid with a filter bar on skills, city and experience_years, and a `gallery` (avatar cover, full_name title). - ats_job: `mine` and `published` (status == 'published') grids. Why the shapes are what they are: the container expander names the default list `.default`, so the application list is named `all` to keep the `default` key for the form the card pins. `mine` and the talent pool carry no scoping filter — which rows a caller may read is the permission set's row-level rule, and a view filter is presentation, not security. The gallery config has no subtitle key, so `current_title` is the first visible field. Export menus offer csv and xlsx on every grid. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- objectstack.config.ts | 4 ++ src/views/application.view.ts | 111 ++++++++++++++++++++++++++++++++++ src/views/candidate.view.ts | 64 ++++++++++++++++++++ src/views/index.ts | 5 ++ src/views/interview.view.ts | 59 ++++++++++++++++++ src/views/job.view.ts | 52 ++++++++++++++++ 6 files changed, 295 insertions(+) create mode 100644 src/views/application.view.ts create mode 100644 src/views/candidate.view.ts create mode 100644 src/views/index.ts create mode 100644 src/views/interview.view.ts create mode 100644 src/views/job.view.ts diff --git a/objectstack.config.ts b/objectstack.config.ts index bea4ab8..1a474ed 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -1,5 +1,6 @@ import { defineStack } from '@objectstack/spec'; import * as objects from './src/objects/index.js'; +import * as views from './src/views/index.js'; import { allHooks } from './src/hooks/index.js'; import { PlatformAdminPosition, @@ -42,6 +43,9 @@ export default defineStack({ objects: Object.values(objects), + // UI — the views each app navigates to (card 07 employer, card 08 platform/seeker). + views: Object.values(views), + // Logic — the stamps that make row-level scoping resolvable (see stamp.hook.ts). hooks: allHooks, diff --git a/src/views/application.view.ts b/src/views/application.view.ts new file mode 100644 index 0000000..0772455 --- /dev/null +++ b/src/views/application.view.ts @@ -0,0 +1,111 @@ +import { defineView } from '@objectstack/spec'; +import type { ListColumn, ListView } from '@objectstack/spec/ui'; + +/** + * Employer-side views over `ats_application` — the pipeline (card 07). + * + * `display_name` is the stored "" mirror the stamp hook + * maintains, so it is a real column: safe to bind, sort and link on. Row + * scope is the permission set's job (row-level rules on `employer_org`), + * never a view filter — these views only decide what a page SHOWS. + */ + +type ViewContainer = Parameters[0]; +type ObjectListView = NonNullable[string]; + +const data = { provider: 'object' as const, object: 'ats_application' }; + +const columns = [ + { field: 'display_name', link: true }, + { field: 'job' }, + { field: 'candidate' }, + { field: 'stage' }, + { field: 'source' }, + { field: 'applied_at' }, + { field: 'rating' }, +] satisfies ListColumn[]; + +/** Newest applications first, on every grid. */ +const sort = [{ field: 'applied_at', order: 'desc' }] satisfies ListView['sort']; + +const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions']; + +/** What every kanban card shows. */ +const cardFields = ['display_name', 'job', 'rating', 'applied_at']; + +/** + * One inbox per working stage. The first inbox is "new" in the employer's + * vocabulary and `applied` in the pipeline's — `stage` has no `new` value + * (DESIGN.md §02), and `applied` is where every application starts. + */ +function inbox( + label: string, + stage: 'applied' | 'screening' | 'interview' | 'offer' | 'hired', +): ObjectListView { + return { + label, + type: 'grid', + data, + columns, + sort, + exportOptions, + filter: [{ field: 'stage', operator: 'equals', value: stage }], + }; +} + +export const ApplicationViews = defineView({ + name: 'ats_application', + label: 'Applications', + object: 'ats_application', + + list: { + // The default list registers as `.` and implicitly claims + // `default` when unnamed, which is the key the form below owns. Naming it + // keeps `ats_application.default` for the form instead of a renamed + // `default_2` with a collision warning. + name: 'all', + label: 'All Applications', + type: 'grid', + data, + columns, + sort, + exportOptions, + }, + + listViews: { + /** The board: one lane per `stage` option, in option order. */ + pipeline: { + label: 'Pipeline', + type: 'kanban', + data, + columns: cardFields, + kanban: { + groupByField: 'stage', + columns: cardFields, + }, + sort, + }, + + inbox_new: inbox('Inbox · New', 'applied'), + inbox_screening: inbox('Inbox · Screening', 'screening'), + inbox_interview: inbox('Inbox · Interview', 'interview'), + inbox_offer: inbox('Inbox · Offer', 'offer'), + inbox_hired: inbox('Inbox · Hired', 'hired'), + }, + + formViews: { + default: { + type: 'simple', + data, + columns: 2, + sections: [ + { + name: 'application', + label: 'Application', + columns: 2, + fields: ['job', 'candidate', 'stage', 'source', 'rating', 'cover_letter'], + }, + ], + }, + }, +}); diff --git a/src/views/candidate.view.ts b/src/views/candidate.view.ts new file mode 100644 index 0000000..c68baa1 --- /dev/null +++ b/src/views/candidate.view.ts @@ -0,0 +1,64 @@ +import { defineView } from '@objectstack/spec'; +import type { ListColumn } from '@objectstack/spec/ui'; + +/** + * The talent pool (card 07). There is no talent-pool object on purpose + * (DESIGN.md §02): it is `ats_candidate` seen through these two views. Which + * candidates an employer may see at all is the permission set's decision; + * the contact and salary fields stay masked by field-level security. + */ + +const data = { provider: 'object' as const, object: 'ats_candidate' }; + +const columns = [ + { field: 'full_name', link: true }, + { field: 'city' }, + { field: 'current_title' }, + { field: 'experience_years' }, + { field: 'skills' }, + { field: 'seeking_status' }, +] satisfies ListColumn[]; + +export const CandidateViews = defineView({ + name: 'ats_candidate', + label: 'Candidates', + object: 'ats_candidate', + + listViews: { + /** Grid with the end-user filter bar on skills, city and experience. */ + talent_pool: { + label: 'Talent Pool', + type: 'grid', + data, + columns, + userFilters: { + element: 'dropdown', + fields: [ + { field: 'skills' }, + { field: 'city' }, + { field: 'experience_years' }, + ], + }, + sort: [{ field: 'full_name', order: 'asc' }], + exportOptions: { formats: ['csv', 'xlsx'] }, + }, + + /** + * Card deck: photo as cover, name as title. The gallery config has no + * subtitle key — the first `visibleFields` entry (`current_title`) is the + * line under the title. + */ + gallery: { + label: 'Gallery', + type: 'gallery', + data, + columns: ['full_name', 'current_title', 'city', 'seeking_status'], + gallery: { + coverField: 'avatar', + titleField: 'full_name', + visibleFields: ['current_title', 'city', 'seeking_status'], + }, + sort: [{ field: 'full_name', order: 'asc' }], + }, + }, +}); diff --git a/src/views/index.ts b/src/views/index.ts new file mode 100644 index 0000000..8038b2d --- /dev/null +++ b/src/views/index.ts @@ -0,0 +1,5 @@ +// Employer views (card 07). Platform and seeker views extend these files (card 08). +export { ApplicationViews } from './application.view.js'; +export { InterviewViews } from './interview.view.js'; +export { CandidateViews } from './candidate.view.js'; +export { JobViews } from './job.view.js'; diff --git a/src/views/interview.view.ts b/src/views/interview.view.ts new file mode 100644 index 0000000..fe38b1a --- /dev/null +++ b/src/views/interview.view.ts @@ -0,0 +1,59 @@ +import { defineView } from '@objectstack/spec'; +import type { ListColumn, ListView } from '@objectstack/spec/ui'; + +/** + * Employer-side views over `ats_interview` (card 07). `scheduled_at` is the + * calendar anchor; `display_name` is the stored " · R" + * mirror, so the event title is a real column. + */ + +const data = { provider: 'object' as const, object: 'ats_interview' }; + +const columns = [ + { field: 'display_name', link: true }, + { field: 'round' }, + { field: 'scheduled_at' }, + { field: 'mode' }, + { field: 'interviewers' }, + { field: 'status' }, + { field: 'rating' }, +] satisfies ListColumn[]; + +const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions']; + +export const InterviewViews = defineView({ + name: 'ats_interview', + label: 'Interviews', + object: 'ats_interview', + + list: { + // Named so the `default` key stays free for a form view (see application.view.ts). + name: 'all', + label: 'All Interviews', + type: 'grid', + data, + columns, + sort: [{ field: 'scheduled_at', order: 'desc' }], + exportOptions, + }, + + listViews: { + /** + * The interview calendar. Events are single-instant (`duration_minutes` + * is a number, not an end timestamp, so there is no `endDateField`); + * colour follows the `status` option colours. + */ + calendar: { + label: 'Interview Calendar', + type: 'calendar', + data, + columns, + calendar: { + startDateField: 'scheduled_at', + titleField: 'display_name', + colorField: 'status', + }, + sort: [{ field: 'scheduled_at', order: 'asc' }], + }, + }, +}); diff --git a/src/views/job.view.ts b/src/views/job.view.ts new file mode 100644 index 0000000..ba7b4f4 --- /dev/null +++ b/src/views/job.view.ts @@ -0,0 +1,52 @@ +import { defineView } from '@objectstack/spec'; +import type { ListColumn, ListView } from '@objectstack/spec/ui'; + +/** + * Employer-side views over `ats_job` (card 07). `mine` carries no filter of + * its own: which employer's jobs a caller sees is the row-level rule on + * `employer_org` (both employer permission sets scope `ats_job` to the + * caller's organizations), and a view filter is presentation, not security. + */ + +const data = { provider: 'object' as const, object: 'ats_job' }; + +const columns = [ + { field: 'title', link: true }, + { field: 'status' }, + { field: 'city' }, + { field: 'employment_type' }, + { field: 'headcount' }, + { field: 'published_at' }, + { field: 'expires_at' }, +] satisfies ListColumn[]; + +const sort = [{ field: 'published_at', order: 'desc' }] satisfies ListView['sort']; + +const exportOptions = { formats: ['csv', 'xlsx'] } satisfies ListView['exportOptions']; + +export const JobViews = defineView({ + name: 'ats_job', + label: 'Jobs', + object: 'ats_job', + + listViews: { + mine: { + label: 'My Jobs', + type: 'grid', + data, + columns, + sort, + exportOptions, + }, + + published: { + label: 'Published Jobs', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'published' }], + sort, + exportOptions, + }, + }, +});