dater format done
parent
fb94db5f41
commit
6c1d4c848e
|
|
@ -236,9 +236,10 @@ FORM_DATA_FIELDS: tuple[str, ...] = tuple(member.value for member in FormDataCol
|
|||
# -- Date parsing ------------------------------------------------------------
|
||||
|
||||
class DateFormat(str, Enum):
|
||||
"""strptime patterns tried in definition order.
|
||||
"""strptime patterns tried in definition order after numeric slash dates.
|
||||
|
||||
DD/MM before MM/DD: 14/10/20 is ambiguous and DD/MM is the local convention.
|
||||
Numeric D/M vs M/D is resolved in parse_date (8/28 → Aug 28, 28/8 → 28 Aug,
|
||||
8/12 follows prefer_mdy). These patterns cover named months and ISO.
|
||||
"""
|
||||
|
||||
D_MON_Y_DASH = "%d-%b-%Y"
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ def stringify_rows(rows):
|
|||
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
|
||||
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
|
||||
_DIGIT_RE=re.compile(r"\d")
|
||||
_NUMERIC_DATE_RE=re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})\2(\d{2,4})$")
|
||||
_AGE_RE=re.compile(r"\d+")
|
||||
_SCORE_RE=re.compile(r"\d+")
|
||||
_SALARY_UNIT_RE=re.compile(
|
||||
|
|
@ -510,8 +511,31 @@ def _normalise_month_spellings(text):
|
|||
return text
|
||||
|
||||
|
||||
def parse_date(value):
|
||||
"""Tolerant date parse → aware UTC datetime, or None. Never raises."""
|
||||
def _from_numeric_date(first,second,year,prefer_mdy):
|
||||
"""Slash/dash/dot numeric dates. 8/28 is MDY; 28/8 is DMY; 8/12 is ambiguous."""
|
||||
if year<100:
|
||||
year+=2000
|
||||
if first>12 and 1<=second<=12:
|
||||
day,month=first,second
|
||||
elif second>12 and 1<=first<=12:
|
||||
month,day=first,second
|
||||
elif prefer_mdy:
|
||||
month,day=first,second
|
||||
else:
|
||||
day,month=first,second
|
||||
try:
|
||||
return datetime(year,month,day,tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(value,prefer_mdy=False):
|
||||
"""Tolerant date parse → aware UTC datetime, or None. Never raises.
|
||||
|
||||
prefer_mdy=True for Google Form Timestamp (US M/D/YYYY). Leave False for
|
||||
local DD/MM fields like date of birth. Unambiguous values (8/28, 28/8)
|
||||
are resolved from the numbers, not the flag.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text=str(value).strip()
|
||||
|
|
@ -532,6 +556,17 @@ def parse_date(value):
|
|||
date_part=_normalise_month_spellings(date_part)
|
||||
date_part=re.sub(r"\s+"," ",date_part).strip(" ,;")
|
||||
|
||||
numeric=_NUMERIC_DATE_RE.match(date_part)
|
||||
if numeric:
|
||||
parsed=_from_numeric_date(
|
||||
int(numeric.group(1)),
|
||||
int(numeric.group(3)),
|
||||
int(numeric.group(4)),
|
||||
prefer_mdy,
|
||||
)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
for fmt in DateFormat:
|
||||
try:
|
||||
return datetime.strptime(date_part,fmt.value).replace(tzinfo=timezone.utc)
|
||||
|
|
@ -541,8 +576,11 @@ def parse_date(value):
|
|||
|
||||
|
||||
def parse_date_time(value):
|
||||
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one."""
|
||||
parsed=parse_date(value)
|
||||
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one.
|
||||
|
||||
Google Form Timestamp is M/D/YYYY, so 8/12/2026 is 12 Aug, not 8 Dec.
|
||||
"""
|
||||
parsed=parse_date(value,prefer_mdy=True)
|
||||
if value is None:
|
||||
return parsed,None
|
||||
text=str(value).strip()
|
||||
|
|
|
|||
|
|
@ -1010,7 +1010,7 @@ async def fetch_interview(
|
|||
recruiter_id:str=Query(None),
|
||||
top:int=Query(None),
|
||||
skip:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
|
|
@ -1038,7 +1038,7 @@ async def fetch_interview(
|
|||
@router.post("/interview/create")
|
||||
async def create_interview(
|
||||
payload:InterviewCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE,PermissionTag.CANDIDATES_CREATE,require_all=False)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
|
|
@ -1055,7 +1055,7 @@ async def create_interview(
|
|||
async def update_interview(
|
||||
interview_id:str=Query(...),
|
||||
payload:InterviewUpdate=...,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT,PermissionTag.CANDIDATES_EDIT,require_all=False)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
-- 028: Slim bundles for custom Access Control roles that should see only the
|
||||
-- Requisitions and Interviews/Calendar tabs. The tags already exist (001, 019);
|
||||
-- the seeded bundles are too wide — requisitions_management includes
|
||||
-- requisitions.manage (org-wide list), analytics_dashboard hangs interviews.view
|
||||
-- off dashboard/analytics/offers, hiring_forms has interview writes but no view.
|
||||
--
|
||||
-- These two are NOT attached to seeded staff roles (those already have the wide
|
||||
-- bundles). Admins tick them on a new role in Access Control.
|
||||
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Own requisitions only (omit .manage so is_admin() stays false)
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'requisitions_self',
|
||||
'Own employee requisition forms: view, create, edit (not org-wide manage)',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('requisitions.view', 'requisitions.create', 'requisitions.edit')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'requisitions_self'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Interviews / Calendar tab (view + schedule + amend)
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'interviews_tab',
|
||||
'Interviews and Calendar tabs: list, schedule, reschedule',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('interviews.view', 'interviews.create', 'interviews.edit')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'interviews_tab'
|
||||
);
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/** Assessments — backend/assessments/app.py. Dual-key: exactly one of inbox_id / manual_upload_candidate_id. */
|
||||
|
||||
|
|
@ -89,9 +90,9 @@ export function toAssessmentView(row) {
|
|||
sectionScores: Array.isArray(row.section_scores) ? row.section_scores : [],
|
||||
duration: durationLabel(row.duration_minutes) || '—',
|
||||
durationMinutes: row.duration_minutes,
|
||||
assigned: row.assigned_at ? new Date(row.assigned_at) : null,
|
||||
due: row.due_at ? new Date(row.due_at) : null,
|
||||
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
||||
remindedAt: row.reminded_at ? new Date(row.reminded_at) : null,
|
||||
assigned: toDate(row.assigned_at),
|
||||
due: toDate(row.due_at),
|
||||
completedAt: toDate(row.completed_at),
|
||||
remindedAt: toDate(row.reminded_at),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/* ============================================================
|
||||
assignments.js — who owns a requisition, and who owns an application.
|
||||
|
|
@ -70,8 +71,8 @@ export function toAssignmentView(row, namesById) {
|
|||
role: row.assignment_role || 'primary_recruiter',
|
||||
jobPostId: row.job_post_id ?? null,
|
||||
inboxId: row.inbox_id ?? null,
|
||||
validFrom: row.valid_from ? new Date(row.valid_from) : null,
|
||||
validTo: row.valid_to ? new Date(row.valid_to) : null,
|
||||
validFrom: toDate(row.valid_from),
|
||||
validTo: toDate(row.valid_to),
|
||||
assignedBy: row.assigned_by ?? null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
import { STATUS_FROM_STAGE } from './pipeline'
|
||||
|
||||
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
|
||||
|
|
@ -158,7 +159,7 @@ export function toCandidateView(row) {
|
|||
scoringStatus: row.status, // 'completed' | 'failed'
|
||||
errorCode: row.error_code ?? null,
|
||||
errorMessage: row.error_message ?? null,
|
||||
applied: row.created_at ? new Date(row.created_at) : null,
|
||||
applied: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +211,7 @@ export function toCandidateUserView(row) {
|
|||
email: row.email ?? null,
|
||||
isActive: row.is_active ?? null,
|
||||
roleName: row.role_name ?? null,
|
||||
applied: row.created_at ? new Date(row.created_at) : null,
|
||||
applied: toDate(row.created_at),
|
||||
// No ATS data on a users row — see the note above.
|
||||
jobId: null,
|
||||
filename: null,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/* ============================================================
|
||||
costs.js — hiring costs, backend/job/app.py `/job/costs/*` (jobs.view / jobs.edit).
|
||||
|
|
@ -38,8 +39,8 @@ export function toCostView(row) {
|
|||
amount: Number(row.amount ?? 0),
|
||||
currency: row.currency || 'USD',
|
||||
description: row.description || null,
|
||||
incurredAt: row.incurred_at ? new Date(row.incurred_at) : null,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
incurredAt: toDate(row.incurred_at),
|
||||
created: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/* ============================================================
|
||||
interviews.js — backend/job/app.py `/interview/*`.
|
||||
|
|
@ -14,9 +15,9 @@ import { request } from '../lib/apiClient'
|
|||
is required" — so `list()` always sends `top`, and the screens never call it
|
||||
bare.
|
||||
|
||||
Permissions are candidates.*, NOT interviews.* — the eight interviews.* tags
|
||||
exist in the catalogue but no route reads them. A user holding only
|
||||
interviews.view gets a 403 here.
|
||||
Permissions are interviews.* OR candidates.* (either tag is enough). A custom
|
||||
role with only the interviews_tab bundle can list/schedule here without
|
||||
candidates.view. Recruiter / hiring_manager still pass via candidates.*.
|
||||
============================================================ */
|
||||
|
||||
/** Status vocabulary. `interview_status` is a free-text column, so this file is
|
||||
|
|
@ -104,7 +105,7 @@ export function update(interviewId, { instant, type, status } = {}) {
|
|||
*/
|
||||
export function toInterviewView(row) {
|
||||
const whenRaw = row.interview_date || row.interview_time
|
||||
const when = whenRaw ? new Date(whenRaw) : null
|
||||
const when = toDate(whenRaw)
|
||||
return {
|
||||
id: row.id,
|
||||
inboxId: row.inbox_id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
import { REQUISITION_STATUSES } from './jobs'
|
||||
|
||||
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||||
|
|
@ -30,7 +31,7 @@ export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) {
|
|||
/** Whole days from created_at to the browser clock. Null if timestamp missing. */
|
||||
export function daysOpen(createdAt, now = Date.now()) {
|
||||
if (!createdAt) return null
|
||||
const start = new Date(createdAt).getTime()
|
||||
const start = toDate(createdAt)?.getTime()
|
||||
if (!Number.isFinite(start)) return null
|
||||
return Math.max(0, Math.floor((now - start) / 86_400_000))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/**
|
||||
* Job requisitions — backend/job/app.py `GET /jobs/fetch`.
|
||||
|
|
@ -82,8 +83,8 @@ export function toJobView(row) {
|
|||
createdByName: row.created_by_name,
|
||||
applicantCount: row.applicant_count ?? 0,
|
||||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
closedAt: row.closed_at ? new Date(row.closed_at) : null,
|
||||
created: toDate(row.created_at),
|
||||
closedAt: toDate(row.closed_at),
|
||||
requisitionStatus: row.requisition_status,
|
||||
experienceMin: row.experience_min,
|
||||
experienceMax: row.experience_max,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/** In-app notifications — backend/notifications/app.py. Scoped to the caller; no RBAC tag. */
|
||||
|
||||
|
|
@ -35,8 +36,8 @@ export function remove(recordId) {
|
|||
|
||||
function relTime(iso) {
|
||||
if (!iso) return ''
|
||||
const then = new Date(iso)
|
||||
if (Number.isNaN(then.getTime())) return ''
|
||||
const then = toDate(iso)
|
||||
if (!then) return ''
|
||||
const mins = Math.max(0, Math.round((Date.now() - then.getTime()) / 60000))
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
if (mins < 1440) return `${Math.floor(mins / 60)}h ago`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/* ============================================================
|
||||
offers.js — backend/offer/app.py.
|
||||
|
|
@ -132,11 +133,11 @@ export function toOfferView(row, { people, jobTitles } = {}) {
|
|||
noticePeriod: row.notice_period || null,
|
||||
workLocation: row.work_location || null,
|
||||
workTimings: row.work_timings || null,
|
||||
startDate: row.start_date ? new Date(row.start_date) : null,
|
||||
expiry: row.expiry_date ? new Date(row.expiry_date) : null,
|
||||
sent: row.sent_at ? new Date(row.sent_at) : null,
|
||||
respondedAt: row.responded_at ? new Date(row.responded_at) : null,
|
||||
closedAt: row.closed_at ? new Date(row.closed_at) : null,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
startDate: toDate(row.start_date),
|
||||
expiry: toDate(row.expiry_date),
|
||||
sent: toDate(row.sent_at),
|
||||
respondedAt: toDate(row.responded_at),
|
||||
closedAt: toDate(row.closed_at),
|
||||
created: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/**
|
||||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||
|
|
@ -214,7 +215,7 @@ export function toBoardCard(row, kind = 'inbox') {
|
|||
experience: asText(row.experience),
|
||||
aiScore: asScore(row.ats_result?.overall_score),
|
||||
recommendation: asText(row.ats_result?.band),
|
||||
applied: row.created_at ? new Date(row.created_at) : null,
|
||||
applied: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
const TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'aborted'])
|
||||
|
||||
|
|
@ -91,9 +92,9 @@ export function toRunView(row) {
|
|||
profilesFound: row.profiles_found ?? 0,
|
||||
costUsd: row.cost_usd ?? null,
|
||||
error: row.apify_error ?? null,
|
||||
startedAt: row.started_at ? new Date(row.started_at) : null,
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at) : null,
|
||||
createdAt: row.created_at ? new Date(row.created_at) : null,
|
||||
startedAt: toDate(row.started_at),
|
||||
finishedAt: toDate(row.finished_at),
|
||||
createdAt: toDate(row.created_at),
|
||||
isTerminal: TERMINAL.has(row.status),
|
||||
}
|
||||
}
|
||||
|
|
@ -104,7 +105,7 @@ export function toAccountView(row) {
|
|||
balanceUsd: r.balance_usd ?? null,
|
||||
spentUsd: r.spent_this_cycle_usd ?? null,
|
||||
monthlyLimitUsd: r.monthly_limit_usd ?? null,
|
||||
cycleEndsAt: r.cycle_ends_at ? new Date(r.cycle_ends_at) : null,
|
||||
cycleEndsAt: toDate(r.cycle_ends_at),
|
||||
// null until at least one search has recorded its cost (runs from before
|
||||
// cost tracking carry no spend data and are excluded from the average).
|
||||
costPerProfileUsd: r.cost_per_profile_usd ?? null,
|
||||
|
|
@ -129,11 +130,11 @@ export function toProfileView(row) {
|
|||
skills: Array.isArray(row.skills) ? row.skills : [],
|
||||
matchScore: row.match_score ?? null,
|
||||
outreachStatus: row.outreach_status ?? 'sourced',
|
||||
shortlistedAt: row.shortlisted_at ? new Date(row.shortlisted_at) : null,
|
||||
shortlistedAt: toDate(row.shortlisted_at),
|
||||
shortlistedByName: row.shortlisted_by_name ?? null,
|
||||
contactedAt: row.contacted_at ? new Date(row.contacted_at) : null,
|
||||
contactedAt: toDate(row.contacted_at),
|
||||
contactedByName: row.contacted_by_name ?? null,
|
||||
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null,
|
||||
lastSeenAt: toDate(row.last_seen_at),
|
||||
// Non-null when a CV in the ATS carries this profile's /in/<slug> link:
|
||||
// { source, status, job_post_id, candidate, applied_at, same_job, applications }
|
||||
alreadyApplied: row.already_applied ?? null,
|
||||
|
|
@ -143,7 +144,7 @@ export function toProfileView(row) {
|
|||
export function toProfileDetailView(row) {
|
||||
return {
|
||||
...toProfileView(row),
|
||||
firstSeenAt: row.first_seen_at ? new Date(row.first_seen_at) : null,
|
||||
firstSeenAt: toDate(row.first_seen_at),
|
||||
experience: (row.experience ?? []).map((e) => ({
|
||||
title: e.title,
|
||||
company: e.company,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
/**
|
||||
* Tasks — backend/tasks/app.py.
|
||||
|
|
@ -44,10 +45,10 @@ export function toTaskView(row) {
|
|||
title: row.title,
|
||||
done: row.status === 'done',
|
||||
priority: row.priority ? row.priority[0].toUpperCase() + row.priority.slice(1) : 'Medium',
|
||||
due: row.due_date ? new Date(row.due_date) : null,
|
||||
due: toDate(row.due_date),
|
||||
assignee: row.assignee_name || '—',
|
||||
assigneeRole: row.assignee_role || null,
|
||||
assigneeId: row.assignee_id,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
created: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@
|
|||
endpoints exist read the API instead; the rest resolve from here.
|
||||
============================================================ */
|
||||
|
||||
import { fmtDate, fmtShort } from '../lib/format'
|
||||
|
||||
// The prototype pinned "today" to 2026-07-09 in ~8 places across five files so
|
||||
// the generated relative dates stayed stable. Exported from one place now, so
|
||||
// switching the app to real time is a one-line change.
|
||||
|
||||
export const TODAY = new Date('2026-07-09T09:00:00');
|
||||
|
||||
// ---------- seeded pseudo-random for stable data ----------
|
||||
|
|
@ -63,13 +66,6 @@ export const TODAY = new Date('2026-07-09T09:00:00');
|
|||
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
|
||||
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
|
||||
function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
|
||||
function fmtDate(d) {
|
||||
if (d == null || d === '') return ''
|
||||
const date = d instanceof Date ? d : new Date(d)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
|
||||
|
||||
const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'];
|
||||
function avatarColor(name) {
|
||||
|
|
@ -233,7 +229,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
|
|||
// ---------- Notifications ----------
|
||||
const notifications = [
|
||||
{ icon: 'user-plus', color: 'i-green', title: 'New application', text: candidates[0].name + ' applied for ' + candidates[0].jobTitle, time: '8m ago', unread: true },
|
||||
{ icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00 PM today', time: '25m ago', unread: true },
|
||||
{ icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00pm today', time: '25m ago', unread: true },
|
||||
{ icon: 'check', color: 'i-teal', title: 'Offer accepted', text: offers[0].candidate + ' accepted the offer 🎉', time: '1h ago', unread: true },
|
||||
{ icon: 'message', color: 'i-purple', title: 'New message', text: 'Hiring manager left feedback on a candidate', time: '2h ago', unread: true },
|
||||
{ icon: 'star', color: 'i-amber', title: 'Assessment completed', text: assessments[0].candidate + ' scored 87% on Coding Challenge', time: '4h ago', unread: false },
|
||||
|
|
|
|||
|
|
@ -9,3 +9,117 @@ export function formatRole(name) {
|
|||
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/* Platform date/time display. One spelling everywhere:
|
||||
date → 3rd Sept 2026
|
||||
time → 7:30pm (12-hour, lowercase, no space)
|
||||
both → 3rd Sept 2026 - 7:30pm
|
||||
|
||||
API timestamps are year-month-day, then optional time, e.g.
|
||||
2026-08-28 03:56:19.685066-07
|
||||
2026-08-28T03:56:19.685066-07:00
|
||||
2026-08-28
|
||||
Display uses those numbers as written: 2026 = year, 08 = month (Aug),
|
||||
28 = day, 03:56 = 3:56am. The timezone suffix is not applied. */
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
const API_TS = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?/
|
||||
|
||||
/** Parse an API timestamp into a Date whose local fields match the payload. */
|
||||
export function toDate(value) {
|
||||
if (value == null || value === '') return null
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
const s = String(value).trim()
|
||||
const m = s.match(API_TS)
|
||||
if (!m) return null
|
||||
const year = Number(m[1])
|
||||
const month = Number(m[2])
|
||||
const day = Number(m[3])
|
||||
const hour = m[4] != null ? Number(m[4]) : 0
|
||||
const minute = m[5] != null ? Number(m[5]) : 0
|
||||
const second = m[6] != null ? Number(m[6]) : 0
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
const d = new Date(year, month - 1, day, hour, minute, second)
|
||||
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null
|
||||
return d
|
||||
}
|
||||
|
||||
function ordinal(n) {
|
||||
const v = n % 100
|
||||
if (v >= 11 && v <= 13) return `${n}th`
|
||||
switch (n % 10) {
|
||||
case 1: return `${n}st`
|
||||
case 2: return `${n}nd`
|
||||
case 3: return `${n}rd`
|
||||
default: return `${n}th`
|
||||
}
|
||||
}
|
||||
|
||||
/** 3rd Sept 2026 */
|
||||
export function fmtDate(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Same as fmtDate — the platform uses one date spelling. */
|
||||
export function fmtShort(value) {
|
||||
return fmtDate(value)
|
||||
}
|
||||
|
||||
/** 7:30pm */
|
||||
export function fmtTime(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
let hours = d.getHours()
|
||||
const minutes = d.getMinutes()
|
||||
const suffix = hours >= 12 ? 'pm' : 'am'
|
||||
hours = hours % 12
|
||||
if (hours === 0) hours = 12
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}${suffix}`
|
||||
}
|
||||
|
||||
/** 3rd Sept 2026 - 7:30pm */
|
||||
export function fmtDateTime(value, sep = ' - ') {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
return `${fmtDate(d)}${sep}${fmtTime(d)}`
|
||||
}
|
||||
|
||||
/** Sept 2026 — calendar month headers, not a day. */
|
||||
export function fmtMonthYear(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Thursday, 3rd Sept 2026 — dashboard "today" line. */
|
||||
export function fmtWeekdayDate(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
return `${WEEKDAYS[d.getDay()]}, ${fmtDate(d)}`
|
||||
}
|
||||
|
||||
export function fmtWeekdayShort(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
return WEEKDAYS_SHORT[d.getDay()]
|
||||
}
|
||||
|
||||
/** yyyy-mm-dd for <input type="date">, from the payload's calendar day. */
|
||||
export function toDateInput(value) {
|
||||
const d = toDate(value)
|
||||
if (!d) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import ChartCard from '../ui/ChartCard'
|
|||
import DataTable from '../ui/DataTable'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { EmptyState, Icon } from '../ui/primitives'
|
||||
import { fmtDate } from '../lib/format'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { RANGES, rangeWindow } from '../lib/timeRanges'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
@ -117,8 +118,8 @@ function AskAnalyticsCard() {
|
|||
<p className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
|
||||
<Icon name="info" /> Answered from the {INTENT_LABELS[result.intent] ?? result.intent} query
|
||||
{result.params?.department ? ` · ${result.params.department}` : ''}
|
||||
{result.params?.from_date ? ` · from ${new Date(result.params.from_date).toLocaleDateString()}` : ''}
|
||||
{result.params?.to_date ? ` · to ${new Date(result.params.to_date).toLocaleDateString()}` : ''}
|
||||
{result.params?.from_date ? ` · from ${fmtDate(result.params.from_date)}` : ''}
|
||||
{result.params?.to_date ? ` · to ${fmtDate(result.params.to_date)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{tableColumns && (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
import { byInboxId, useApplications } from '../lib/useApplications'
|
||||
import { fmtDate, fmtMonthYear, fmtTime } from '../lib/format'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
/* Round -> event colour. Unknown rounds fall through to blue rather than
|
||||
|
|
@ -105,7 +106,7 @@ export default function Calendar() {
|
|||
}, [events])
|
||||
|
||||
const cells = useMemo(() => buildCells(year, month), [year, month])
|
||||
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
const monthName = fmtMonthYear(new Date(year, month, 1))
|
||||
const todayKey = today.toDateString()
|
||||
const todayIvs = byDay.get(todayKey) ?? []
|
||||
|
||||
|
|
@ -157,7 +158,7 @@ export default function Calendar() {
|
|||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load the calendar">
|
||||
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
|
||||
{' '}This screen needs the <code>candidates.view</code> permission.
|
||||
{' '}This screen needs the <code>interviews.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -180,7 +181,7 @@ export default function Calendar() {
|
|||
title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`}
|
||||
onClick={() => openCandidate(iv.userId)}
|
||||
>
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
|
||||
{fmtTime(iv.when)} {iv.candidate.split(' ')[0]}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
|
|
@ -198,7 +199,7 @@ export default function Calendar() {
|
|||
<div>
|
||||
<h3>Today</h3>
|
||||
<span className="ch-sub">
|
||||
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
{fmtDate(today)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -227,7 +228,7 @@ export default function Calendar() {
|
|||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600 text-sm">
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
{fmtTime(iv.when)}
|
||||
</div>
|
||||
{iv.webLink && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
|||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { isHiringManager } from '../auth/permissions'
|
||||
import { fmtDate, toDateInput } from '../lib/format'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as formsApi from '../api/forms'
|
||||
|
|
@ -53,12 +54,6 @@ function titleCase(status) {
|
|||
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Shortlist'
|
||||
}
|
||||
|
||||
function toDateInput(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Same shape as the profile's useProfileWrite, plus the forms/offers caches. */
|
||||
function useFormsWrite({ userId, mutationFn, success, onDone }) {
|
||||
const qc = useQueryClient()
|
||||
|
|
@ -276,7 +271,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
|
|||
<div className="lr-main">
|
||||
<div className="lr-title">{r.interviewer_name || r.created_by_name || 'Unknown'}</div>
|
||||
<div className="lr-sub">
|
||||
{toDateInput(r.form_date) || toDateInput(r.created_at)}
|
||||
{fmtDate(r.form_date) || fmtDate(r.created_at) || '—'}
|
||||
{r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import * as formsApi from '../api/forms'
|
|||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as s3Api from '../api/s3'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
import { fmtDate, fmtTime, toDate } from '../lib/format'
|
||||
import { companies, moneyK, pick } from '../data/seed'
|
||||
|
||||
/* Workflow order: learn (Overview, Resume, Documents) → interview (Interview,
|
||||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
|
|
@ -30,22 +31,19 @@ const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture
|
|||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
||||
/** Seed timestamps are Date objects; the API sends ISO strings. */
|
||||
/** Seed timestamps are Date objects; the API sends YYYY-MM-DD[ T]HH:MM strings. */
|
||||
function fmtWhen(value, fallback = '—') {
|
||||
if (!value) return fallback
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d)
|
||||
return fmtDate(value) || fallback
|
||||
}
|
||||
|
||||
function fmtClock(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
return fmtTime(value) || null
|
||||
}
|
||||
|
||||
function stamp(value) {
|
||||
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
|
||||
const d = toDate(value)
|
||||
return d ? d.getTime() : 0
|
||||
}
|
||||
|
||||
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
|
||||
|
|
@ -749,8 +747,8 @@ const HISTORY_TITLE = {
|
|||
}
|
||||
|
||||
function historyDayLabel(value) {
|
||||
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||
if (Number.isNaN(d.getTime())) return 'Unknown'
|
||||
const d = toDate(value)
|
||||
if (!d) return 'Unknown'
|
||||
const today = new Date()
|
||||
const yday = new Date()
|
||||
yday.setDate(today.getDate() - 1)
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ function HiringManagerCandidates() {
|
|||
sortValue: (r) => r.created_at || '',
|
||||
render: (r) => (
|
||||
<span className="text-muted">
|
||||
{r.created_at ? fmtDate(new Date(r.created_at)) : '—'}
|
||||
{r.created_at ? fmtDate(r.created_at) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -497,18 +497,18 @@ function RecruiterCandidates() {
|
|||
await exportStyledXlsx({
|
||||
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
|
||||
title: 'Candidates',
|
||||
subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
|
||||
subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
|
||||
columns: [
|
||||
{ header: 'Name', key: 'name', width: 26 },
|
||||
{ header: 'Email', key: 'email', width: 30 },
|
||||
{ header: 'Role', key: 'role', width: 22 },
|
||||
{ header: 'Applied', key: 'applied', width: 12 },
|
||||
{ header: 'Applied', key: 'applied', width: 16 },
|
||||
{ header: 'Source', key: 'source', width: 14 },
|
||||
{ header: 'Account', key: 'account', width: 12 },
|
||||
],
|
||||
rows: rows.map((c) => ({
|
||||
name: c.name, email: c.email, role: formatRole(c.roleName),
|
||||
applied: c.applied ? c.applied.toLocaleDateString() : '',
|
||||
applied: c.applied ? fmtDate(c.applied) : '',
|
||||
source: c.source,
|
||||
account: c.isActive ? 'Active' : 'Unconfirmed',
|
||||
})),
|
||||
|
|
@ -646,7 +646,7 @@ function RecruiterCandidates() {
|
|||
</td>
|
||||
<td>
|
||||
<span className="text-sm">
|
||||
{c.applied ? c.applied.toLocaleDateString() : '—'}
|
||||
{c.applied ? fmtDate(c.applied) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -729,9 +729,7 @@ const asList = (value) => (Array.isArray(value) ? value : [])
|
|||
|
||||
/** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */
|
||||
function fmtStamp(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
|
||||
return fmtDate(value) || null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
|||
import { useToast } from '../ui/Toast'
|
||||
import JobCandidates from './JobCandidates'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { fmtDate } from '../lib/format'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as s3Api from '../api/s3'
|
||||
|
|
@ -429,7 +430,7 @@ function CvBank() {
|
|||
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
|
||||
<div className="cell-sub">
|
||||
{r.candidate_email || 'No email detected'}
|
||||
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
|
||||
{r.created_at ? ` · added ${fmtDate(r.created_at)}` : ''}
|
||||
</div>
|
||||
{r.linkedin_url ? (
|
||||
<div className="cell-sub" style={{ marginTop: 2 }}>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ import { useAuth } from '../auth/AuthContext'
|
|||
import { isHiringManager } from '../auth/permissions'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
|
||||
import { fmtShort, money } from '../data/seed'
|
||||
import { fmtWeekdayDate, fmtShort } from '../lib/format'
|
||||
import { money } from '../data/seed'
|
||||
import * as activityApi from '../api/activity'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
|
|
@ -94,18 +95,11 @@ function greetingFor(now = new Date()) {
|
|||
}
|
||||
|
||||
function formatDashDate(d = new Date()) {
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
return fmtWeekdayDate(d)
|
||||
}
|
||||
|
||||
function fmtWhen(iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : fmtShort(d)
|
||||
return fmtShort(iso) || '—'
|
||||
}
|
||||
|
||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ import { useAuth } from '../auth/AuthContext'
|
|||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { formatRole } from '../lib/format'
|
||||
import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as sheetApi from '../api/sheet'
|
||||
import * as s3Api from '../api/s3'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf,
|
||||
atsRecommendationClass, avatarColor, initials as initialsOf,
|
||||
inboxSources, sourceMeta,
|
||||
} from '../data/seed'
|
||||
|
||||
|
|
@ -101,44 +101,11 @@ const SERVER_SCOPED_TABS = new Set(TABS)
|
|||
|
||||
/**
|
||||
* message_received_time / message_sent_time are plain string columns
|
||||
* (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
|
||||
* an Invalid Date that every fmt* helper renders as the literal "Invalid Date",
|
||||
* so return null instead and let the call sites decide what to show.
|
||||
* (backend/inbox/models.py:54-56), not timestamps. Unparseable values
|
||||
* become null — never "Invalid Date".
|
||||
*/
|
||||
function parseDate(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function startOfDay(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlook-style list timestamps in the client's local timezone.
|
||||
* Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only — like Outlook.
|
||||
* The date-plus-time form was ~105px wide, and in the 380px queue rail that
|
||||
* squeezed .ii-main to 148px: sender names painted over the timestamp and the
|
||||
* meta chips wrapped one-per-line. The full timestamp is in the detail pane.
|
||||
*/
|
||||
function outlookListTime(value) {
|
||||
if (!value) return '—'
|
||||
const d = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
|
||||
if (daysAgo < 7) {
|
||||
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
const time = d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
return `${weekday} ${time}`
|
||||
}
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${dd}/${mm}/${d.getFullYear()}`
|
||||
return toDate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -160,12 +127,65 @@ function sourceFrom(messageTo) {
|
|||
/** Sheet Forms always chip as Google Sheet — not the form's "where did you hear" answer. */
|
||||
const FORM_LIST_SOURCE = { source: 'Google Sheet', sourceMeta: SHEET_SOURCE_META }
|
||||
|
||||
/** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */
|
||||
function formReceivedAt(entryDate, entryTime) {
|
||||
const d = parseDate(entryDate)
|
||||
/** Google Form "Timestamp" cell — US M/D/YYYY, e.g. 8/12/2026 8:25:19 → 12th Aug. */
|
||||
function rawFormTimestamp(row) {
|
||||
const raw = row?.raw_record
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (String(key).trim().toLowerCase() === 'timestamp' && val != null && String(val).trim()) {
|
||||
return String(val).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseFormTimestamp(value) {
|
||||
if (value == null || value === '') return null
|
||||
const s = String(value).trim()
|
||||
const m = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})(?:[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?)?/i)
|
||||
if (m) {
|
||||
const a = Number(m[1])
|
||||
const b = Number(m[2])
|
||||
let year = Number(m[3])
|
||||
if (year < 100) year += 2000
|
||||
let month
|
||||
let day
|
||||
if (a > 12 && b <= 12) {
|
||||
day = a
|
||||
month = b
|
||||
} else {
|
||||
month = a
|
||||
day = b
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
let hours = m[4] != null ? Number(m[4]) : 0
|
||||
const minutes = m[5] != null ? Number(m[5]) : 0
|
||||
const seconds = m[6] != null ? Number(m[6]) : 0
|
||||
const ap = (m[7] || '').toLowerCase()
|
||||
if (ap === 'pm' && hours < 12) hours += 12
|
||||
if (ap === 'am' && hours === 12) hours = 0
|
||||
const d = new Date(year, month - 1, day, hours, minutes, seconds)
|
||||
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null
|
||||
return d
|
||||
}
|
||||
return toDate(s)
|
||||
}
|
||||
|
||||
/** Prefer the original sheet Timestamp so already-imported swapped dates still display right. */
|
||||
function formReceivedAt(entryDate, entryTime, timestampRaw) {
|
||||
const fromSheet = parseFormTimestamp(timestampRaw)
|
||||
if (fromSheet) return fromSheet
|
||||
const d = toDate(entryDate)
|
||||
if (!d) return null
|
||||
const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/)
|
||||
if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0)
|
||||
const m = String(entryTime || '').trim().match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i)
|
||||
if (m) {
|
||||
let hours = Number(m[1])
|
||||
const minutes = Number(m[2])
|
||||
const ap = (m[3] || '').toLowerCase()
|
||||
if (ap === 'pm' && hours < 12) hours += 12
|
||||
if (ap === 'am' && hours === 12) hours = 0
|
||||
d.setHours(hours, minutes, 0, 0)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +226,7 @@ function mapFormRow(row) {
|
|||
phone: row.candidate_number || '',
|
||||
position: row.position_applied_for || '—',
|
||||
...FORM_LIST_SOURCE,
|
||||
received: formReceivedAt(row.entry_date, row.entry_time),
|
||||
received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)),
|
||||
screenedBy: row.screened_by || '',
|
||||
hrComments: row.hr_comments || '',
|
||||
gender: row.gender || '',
|
||||
|
|
@ -1093,7 +1113,7 @@ export default function Inbox() {
|
|||
{ header: 'Position', key: 'position', width: 34 },
|
||||
{ header: 'Channel', key: 'channel', width: 12 },
|
||||
{ header: 'Source', key: 'source', width: 20 },
|
||||
{ header: 'Received', key: 'received', width: 12 },
|
||||
{ header: 'Received', key: 'received', width: 16 },
|
||||
{ header: 'Status', key: 'status', width: 12 },
|
||||
{ header: 'City', key: 'city', width: 14 },
|
||||
{ header: 'Notice period', key: 'notice', width: 13 },
|
||||
|
|
@ -1104,7 +1124,7 @@ export default function Inbox() {
|
|||
name: r.name, email: r.email, phone: r.phone, position: r.position,
|
||||
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
|
||||
source: r.source,
|
||||
received: r.received ? r.received.toISOString().slice(0, 10) : '',
|
||||
received: r.received ? fmtDate(r.received) : '',
|
||||
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
|
||||
ats: r.atsScore, job: r.assignedPost?.title,
|
||||
})),
|
||||
|
|
@ -1340,14 +1360,16 @@ export default function Inbox() {
|
|||
<div className="ii-pos">{i.position}</div>
|
||||
<div className="ii-aside">
|
||||
<div className="ii-time">
|
||||
{outlookListTime(i.received)}
|
||||
{i.received ? (
|
||||
<>
|
||||
<div>{fmtDate(i.received)}</div>
|
||||
<div>{fmtTime(i.received)}</div>
|
||||
</>
|
||||
) : '—'}
|
||||
</div>
|
||||
{asAtsScore(i.atsScore) != null && (
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
)}
|
||||
{i.kind === 'form' && i.noticePeriod && (
|
||||
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ii-meta">
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||
|
|
@ -1710,7 +1732,7 @@ function FormApplicantDetail({
|
|||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
|
||||
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
|
||||
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
|
||||
|
|
@ -1746,68 +1768,6 @@ function FormApplicantDetail({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Matching roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
<div className="fw-600" style={{ marginBottom: 8 }}>Matching roles</div>
|
||||
<div className="cell-sub" style={{ marginBottom: 10 }}>
|
||||
Matched by position applied for: {orDash(i.position)}
|
||||
{i.jobPosts?.length > 1 ? ` · ${i.jobPosts.length} roles` : ''}
|
||||
</div>
|
||||
{matchCards.length === 0 && !manualPost ? (
|
||||
<EmptyState icon="alert" title="No matching roles">
|
||||
<p>No job post title matches this position. Choose a role manually.</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={() => setShowPicker(true)}
|
||||
>
|
||||
Choose a role
|
||||
</button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
matchCards.map(({ rank, post }) => (
|
||||
<JobCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
rank={rank}
|
||||
badge={`Match #${rank}`}
|
||||
selected={String(selection) === String(post.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{manualPost && (
|
||||
<JobCard
|
||||
post={manualPost}
|
||||
rank={0}
|
||||
manual
|
||||
selected={String(selection) === String(manualPost.id)}
|
||||
onSelect={(id) => setSelection(String(id))}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
disabled={!canEdit}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={() => setShowPicker(true)}
|
||||
>
|
||||
Choose a different role…
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
disabled={!canAssign}
|
||||
title={!canEdit ? 'Requires inbox.edit' : undefined}
|
||||
onClick={() => {
|
||||
if (!selection || !canEdit) return
|
||||
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
|
||||
}}
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Matching roles"
|
||||
|
|
@ -2086,10 +2046,10 @@ function ApplicationDetail({
|
|||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div>
|
||||
</div>
|
||||
{i.sentAt && (
|
||||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
|
||||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDateTime(i.sentAt)}</div></div>
|
||||
)}
|
||||
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
|
||||
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ import * as candidatesApi from '../api/candidates'
|
|||
import * as feedbackApi from '../api/feedback'
|
||||
import { INTERVIEW_STATUSES, INTERVIEW_TYPES } from '../api/interviews'
|
||||
import { byInboxId, useApplications } from '../lib/useApplications'
|
||||
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
|
||||
import { fmtShort, fmtTime } from '../lib/format'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const FETCH_TOP = 200
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ function toInstant(date, time) {
|
|||
}
|
||||
|
||||
function clock(d) {
|
||||
return d ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '—'
|
||||
return fmtTime(d) || '—'
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
|
|
@ -313,7 +314,7 @@ export default function Interviews() {
|
|||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load interviews">
|
||||
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
|
||||
{' '}This screen needs the <code>candidates.view</code> permission.
|
||||
{' '}This screen needs the <code>interviews.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { platformLabel, platformOptions, platformService } from '../lib/platforms'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import { toDate } from '../lib/format'
|
||||
import { fmtShort } from '../data/seed'
|
||||
|
||||
const POST_LIMIT = 300
|
||||
|
|
@ -74,10 +75,10 @@ export default function JobBoard() {
|
|||
status: row.status || 'draft',
|
||||
link: row.buffer_external_link || null,
|
||||
postId: row.buffer_post_id || null,
|
||||
sentAt: row.buffer_sent_at ? new Date(row.buffer_sent_at) : null,
|
||||
sentAt: toDate(row.buffer_sent_at),
|
||||
error: row.buffer_error || null,
|
||||
isActive: row.is_active,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
created: toDate(row.created_at),
|
||||
createdBy: row.created_by_name || null,
|
||||
location: row.location || null,
|
||||
employmentType: row.employment_type || null,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ import * as assignmentsApi from '../api/assignments'
|
|||
import * as tasksApi from '../api/tasks'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import { empTypes, fmtShort } from '../data/seed'
|
||||
import { fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||
import { empTypes } from '../data/seed'
|
||||
|
||||
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
|
||||
const JOB_LIMIT = 100
|
||||
|
|
@ -407,10 +408,7 @@ const MAX_IMAGE_MB = 5
|
|||
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
|
||||
|
||||
function fmtWhen(value) {
|
||||
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
return `${fmtShort(d)} · ${clock}`
|
||||
return fmtDateTime(value) || null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1227,7 +1225,7 @@ function JobHistory({ historyQuery, statusQuery }) {
|
|||
...statusRows.map((row) => ({
|
||||
kind: 'status',
|
||||
id: `s-${row.id}`,
|
||||
at: row.created_at ? new Date(row.created_at) : null,
|
||||
at: toDate(row.created_at),
|
||||
row,
|
||||
})),
|
||||
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import * as candidatesApi from '../api/candidates'
|
|||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as s3Api from '../api/s3'
|
||||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
import { toDate } from '../lib/format'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'needs', label: 'Needs assignment' },
|
||||
|
|
@ -42,12 +43,6 @@ const PAGE_SIZE_MAX = 500
|
|||
|
||||
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function mapRow(row) {
|
||||
const name = row.name || row.email || row.file_name || 'Unknown'
|
||||
return {
|
||||
|
|
@ -59,7 +54,7 @@ function mapRow(row) {
|
|||
position: row.file_name || 'CV bank',
|
||||
source: 'CV bank',
|
||||
sourceMeta: CV_BANK_META,
|
||||
received: parseDate(row.created_at),
|
||||
received: toDate(row.created_at),
|
||||
resumeText: row.resume_text || '',
|
||||
filePath: row.file_path || '',
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import * as analyticsApi from '../api/analytics'
|
|||
import * as costsApi from '../api/costs'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as reportsApi from '../api/reports'
|
||||
import { fmtDate, fmtDateTime, toDate } from '../lib/format'
|
||||
import { money } from '../data/seed'
|
||||
|
||||
const DEPT_CAP = 12
|
||||
|
|
@ -108,7 +109,7 @@ function reportWindowLabel(filters) {
|
|||
|
||||
function runSubtitle(result) {
|
||||
const win = result?.window || {}
|
||||
const fmt = (v) => (v ? new Date(v).toLocaleDateString() : null)
|
||||
const fmt = (v) => (v ? fmtDate(v) || null : null)
|
||||
const from = fmt(win.from_date)
|
||||
const to = fmt(win.to_date)
|
||||
const range = from || to ? `${from ?? '…'} – ${to ?? 'now'}` : 'All time'
|
||||
|
|
@ -383,9 +384,9 @@ export default function Reports() {
|
|||
{ key: '_window', label: 'Window', render: (r) => reportWindowLabel(r.filters) },
|
||||
{
|
||||
key: 'last_run_at', label: 'Last Run', sortable: true,
|
||||
sortValue: (r) => (r.last_run_at ? new Date(r.last_run_at).getTime() : 0),
|
||||
sortValue: (r) => toDate(r.last_run_at)?.getTime() ?? 0,
|
||||
render: (r) => (r.last_run_at
|
||||
? new Date(r.last_run_at).toLocaleString()
|
||||
? fmtDateTime(r.last_run_at)
|
||||
: <span className="text-muted">never</span>),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import { EMPLOYMENT_TYPES, EMPLOYMENT_TYPE_LABEL, approvalStatus } from '../api/requisitions'
|
||||
import { fmtShort } from '../data/seed'
|
||||
import { fmtShort, toDateInput } from '../lib/format'
|
||||
|
||||
const TYPE_BADGE = {
|
||||
permanent: 'b-indigo',
|
||||
|
|
@ -36,12 +36,6 @@ const STATUS_BADGE = {
|
|||
open: 'b-indigo',
|
||||
}
|
||||
|
||||
function toDateInput(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? String(value).slice(0, 10) : d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function emptyToNull(value) {
|
||||
if (value === '' || value == null) return null
|
||||
return value
|
||||
|
|
@ -143,7 +137,7 @@ export default function Requisitions() {
|
|||
sortValue: (r) => r.position?.date_needed || '',
|
||||
render: (r) => (
|
||||
<span className="text-muted">
|
||||
{r.position?.date_needed ? fmtShort(new Date(r.position.date_needed)) : '—'}
|
||||
{r.position?.date_needed ? fmtShort(r.position.date_needed) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -155,7 +149,7 @@ export default function Requisitions() {
|
|||
render: (r) => (
|
||||
<>
|
||||
<div className="cell-primary text-sm">{r.initiated_by || '—'}</div>
|
||||
<div className="cell-sub">{r.initiated_date ? fmtShort(new Date(r.initiated_date)) : ''}</div>
|
||||
<div className="cell-sub">{r.initiated_date ? fmtShort(r.initiated_date) : ''}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ function formatExperience(value, unit) {
|
|||
|
||||
/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */
|
||||
function fmtStamp(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
|
||||
return fmtDate(value) || null
|
||||
}
|
||||
|
||||
function useCandidateDetail(userId) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useFormState } from '../components/AuthLayout'
|
|||
import { usePermission } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { formatRole } from '../lib/format'
|
||||
import { formatRole, fmtDate } from '../lib/format'
|
||||
import * as rolesApi from '../api/roles'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as orgSettingsApi from '../api/orgSettings'
|
||||
|
|
@ -331,7 +331,7 @@ function Users() {
|
|||
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
|
||||
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
|
|
@ -427,7 +427,7 @@ function Approvals() {
|
|||
</div>
|
||||
</td>
|
||||
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
|
||||
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
|
||||
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ function AppliedBadge({ applied }) {
|
|||
const tip = [
|
||||
applied.candidate,
|
||||
applied.status ? `status ${applied.status}` : null,
|
||||
applied.applied_at ? `applied ${new Date(applied.applied_at).toLocaleDateString()}` : null,
|
||||
applied.applied_at ? `applied ${fmtDate(applied.applied_at)}` : null,
|
||||
applied.applications > 1 ? `${applied.applications} applications` : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { fmtDate } from '../lib/format'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
/** Backend GET /candidate/fetch caps `limit` at 100. */
|
||||
|
|
@ -258,7 +259,7 @@ export default function TalentPool() {
|
|||
await exportStyledXlsx({
|
||||
filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
|
||||
title: 'Talent Pool',
|
||||
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
|
||||
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
|
||||
columns: [
|
||||
{ header: 'Name', key: 'name', width: 24 },
|
||||
{ header: 'Email', key: 'email', width: 28 },
|
||||
|
|
|
|||
|
|
@ -1020,7 +1020,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.ii-name { font-weight: 600; font-size: var(--fs-base); display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
|
||||
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
.ii-time { font-size: 11px; color: var(--text-3); line-height: 1.35; }
|
||||
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
||||
Username (.ii-name) and subject (.ii-pos) are left alone.
|
||||
The list column yields (34%, floor 280px) instead of holding a hard 420px,
|
||||
|
|
|
|||
Loading…
Reference in New Issue