frontend integrated
parent
c726bbacbc
commit
a81107cb96
|
|
@ -2,6 +2,7 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
|
|
@ -14,12 +15,19 @@ def _now() -> datetime:
|
|||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# Every datetime below is aware (see _now, and the API parses ISO input carrying
|
||||
# an offset), so each column is declared timestamptz. SQLModel maps a bare
|
||||
# `datetime` to TIMESTAMP WITHOUT TIME ZONE, and asyncpg refuses to bind an aware
|
||||
# value to one — "can't subtract offset-naive and offset-aware datetimes" — which
|
||||
# turns every insert here into a 500. Same pairing as job/job_post/models.py.
|
||||
|
||||
|
||||
class Interviews(SQLModel, table=True):
|
||||
__tablename__ = "interviews"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
interview_date: datetime = Field(default_factory=_now)
|
||||
interview_time: datetime = Field(default_factory=_now)
|
||||
interview_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
interview_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
interview_type: str = Field(default="")
|
||||
interview_status: str = Field(default="")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
|
|
@ -75,8 +83,8 @@ class Notes(SQLModel, table=True):
|
|||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
note: str = Field(default="")
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
updated_at: datetime = Field(default_factory=_now)
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
user: Optional["Users"] = Relationship(
|
||||
|
|
@ -139,8 +147,8 @@ class Activity(SQLModel, table=True):
|
|||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
activity_type: str = Field(default="")
|
||||
activity_date: datetime = Field(default_factory=_now)
|
||||
activity_time: datetime = Field(default_factory=_now)
|
||||
activity_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
activity_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
activity_status: str = Field(default="")
|
||||
description: str | None = Field(default=None)
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
|
|
@ -199,8 +207,8 @@ class Feedback(SQLModel, table=True):
|
|||
financial_status: str = Field(default="")
|
||||
score: float = Field(default=0.0)
|
||||
note: str | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
updated_at: datetime = Field(default_factory=_now)
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
reviewed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
user: Optional["Users"] = Relationship(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export function list({ search, limit, offset } = {}) {
|
|||
/**
|
||||
* One candidate by users.id.
|
||||
*
|
||||
* Passing user_id switches the endpoint into DETAIL mode
|
||||
* (backend/job/candidate/views.py:get_candidate), which is a different and much
|
||||
* larger payload than the list rows: résumé text, the AI match verdict, phone,
|
||||
* education, source, documents, favorite/rating, and the four child collections
|
||||
* — interviews, activity, feedback, notes — flattened across every inbox row the
|
||||
* candidate owns.
|
||||
*
|
||||
* NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
|
||||
* rather than a one-element list when user_id matches exactly one row
|
||||
* (backend/inbox/models.py:68-70). Callers must normalise — see toRows().
|
||||
|
|
@ -30,3 +37,58 @@ export function toRows(res) {
|
|||
if (Array.isArray(res?.data)) return res.data
|
||||
return res?.data ? [res.data] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* favorite/rating live on the `inbox` row, not on the user, so the server applies
|
||||
* the change to EVERY application belonging to the candidate and hands back the
|
||||
* refreshed detail payload. Pipeline stage is not writable here — no endpoint
|
||||
* updates inbox_messages.application_status yet.
|
||||
*/
|
||||
export function update(userId, payload) {
|
||||
return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Child records of a profile.
|
||||
|
||||
Reads are deliberately absent: the detail payload above already bundles all
|
||||
four collections, so a separate GET per tab would be a second round trip for
|
||||
data the modal is holding. Writers invalidate qk.candidates.detail(userId) and
|
||||
the whole modal repaints from one refetch.
|
||||
|
||||
Scoping differs by table and is not interchangeable — notes hang off the
|
||||
candidate (users.id), while interviews, activity and feedback hang off one
|
||||
application (inbox.id).
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
export function createNote({ userId, note }) {
|
||||
return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })
|
||||
}
|
||||
|
||||
export function createInterview({ inboxId, date, time, type, status }) {
|
||||
return request('/interview/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
inbox_id: inboxId,
|
||||
interview_date: date,
|
||||
interview_time: time,
|
||||
interview_type: type,
|
||||
interview_status: status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** `reviewed_by` is omitted on purpose: the server stamps the caller. */
|
||||
export function createFeedback({ inboxId, review, score, note }) {
|
||||
return request('/feedback/create', {
|
||||
method: 'POST',
|
||||
body: { inbox_id: inboxId, review, score, note },
|
||||
})
|
||||
}
|
||||
|
||||
export function createActivity({ inboxId, type, status, description }) {
|
||||
return request('/activity/create', {
|
||||
method: 'POST',
|
||||
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'
|
|||
import { NAV_GROUPS, ROUTES } from './routes'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import Icon from '../ui/icons'
|
||||
import BrandMark from '../components/BrandMark'
|
||||
import { BrandGlyph } from '../components/BrandMark'
|
||||
|
||||
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
|
||||
const { can } = useAuth()
|
||||
|
|
@ -18,7 +18,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
|
|||
>
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-logo">
|
||||
<BrandMark />
|
||||
<BrandGlyph />
|
||||
</div>
|
||||
<div className="brand-text">
|
||||
<span className="brand-name">TalentFlow</span>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
/* The wordmark, in two pieces.
|
||||
|
||||
BrandGlyph is the emblem on its own. The sidebar paints its own `.brand-logo`
|
||||
tile and its own `.brand-name`, so handing it the full BrandMark nested one
|
||||
green tile inside another and printed "TalentFlow" twice — once inside the
|
||||
tile, once beside it. Callers that already supply their own chrome take the
|
||||
glyph; callers that want the whole lockup (the auth screens) take the default. */
|
||||
|
||||
export function BrandGlyph() {
|
||||
return (
|
||||
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BrandMark({ size = 'md', showName = true }) {
|
||||
const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo'
|
||||
return (
|
||||
<div className="auth-brand">
|
||||
<div className={logoClass}>
|
||||
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z" />
|
||||
</svg>
|
||||
<BrandGlyph />
|
||||
</div>
|
||||
{showName ? <span className="brand-name">TalentFlow</span> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,106 @@
|
|||
/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the
|
||||
single largest block in js/candidates.js and deserves its own file. */
|
||||
single largest block in js/candidates.js and deserves its own file.
|
||||
|
||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||
|
||||
SEED (Candidates.jsx) — every tab renders from the seed record, exactly as
|
||||
the prototype did.
|
||||
LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint
|
||||
into detail mode and returns the real record: résumé text, the agent's
|
||||
match verdict, documents, and the four child collections (interviews,
|
||||
notes, activity, feedback). The write tabs POST to their own endpoints
|
||||
and invalidate this one query, so the whole modal repaints from a single
|
||||
refetch.
|
||||
|
||||
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
|
||||
an empty state, because inventing three scorecards for a real applicant is
|
||||
worse than showing none.
|
||||
|
||||
Scoping differs between the child tables and is not interchangeable: notes
|
||||
hang off the candidate (users.id), while interviews, activity and feedback
|
||||
hang off one application (inbox.id). */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||
|
||||
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||||
|
||||
/** Seed timestamps are Date objects; the API sends ISO 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)
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
function stamp(value) {
|
||||
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
|
||||
}
|
||||
|
||||
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
|
||||
function toInstant(date, time) {
|
||||
if (!date) return null
|
||||
const d = new Date(`${date}T${time || '00:00'}`)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||
}
|
||||
|
||||
function Info({ label, val }) {
|
||||
return (
|
||||
<div className="info-item">
|
||||
<div className="il">{label}</div>
|
||||
<div className="iv">{val === 0 || val ? val : '—'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useCandidateDetail(userId) {
|
||||
return useQuery({
|
||||
queryKey: qk.candidates.detail(userId),
|
||||
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
|
||||
enabled: Boolean(userId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A write against one of the child endpoints. Every one of them invalidates the
|
||||
* single detail query the modal renders from, so a saved note and a submitted
|
||||
* scorecard both land through the same refetch rather than through hand-patched
|
||||
* cache entries that could drift from the server's view.
|
||||
*/
|
||||
function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: async (_data, vars) => {
|
||||
await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })
|
||||
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
||||
onDone?.()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
|
||||
})
|
||||
}
|
||||
|
||||
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
|
||||
const { toast } = useToast()
|
||||
|
|
@ -20,12 +108,53 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
|
||||
const isLive = Boolean(c.userId)
|
||||
const detail = useCandidateDetail(c.userId)
|
||||
const live = detail.data ?? null
|
||||
|
||||
// The prototype called DB.pick() inline while rendering, so the "previous
|
||||
// employer" changed every repaint. Fixed per candidate.
|
||||
const priorCompany = useMemo(() => pick(companies), [])
|
||||
|
||||
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
|
||||
|
||||
// The application row interviews/activity/feedback attach to. Detail mode
|
||||
// flattens every application the candidate owns; writes land on the first,
|
||||
// which is the one the header is describing.
|
||||
const inboxId = live?.inbox_id ?? null
|
||||
|
||||
const favorite = live ? Boolean(live.favorite) : c.favorite
|
||||
const setFavorite = useProfileWrite({
|
||||
userId: c.userId,
|
||||
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
|
||||
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
|
||||
})
|
||||
|
||||
const title = live?.job_title || c.currentTitle
|
||||
const company = live?.currentCompany || c.currentCompany
|
||||
|
||||
const counts = live && {
|
||||
Interview: live.interviews?.length ?? 0,
|
||||
Notes: live.notes?.length ?? 0,
|
||||
Activity: live.activity?.length ?? 0,
|
||||
Documents: live.documents?.length ?? 0,
|
||||
Feedback: live.feedback?.length ?? 0,
|
||||
}
|
||||
|
||||
// In live mode nothing below the hero can be trusted until the detail payload
|
||||
// lands, so one guard replaces every tab body rather than each tab inventing
|
||||
// its own half-loaded state.
|
||||
const guard = !isLive ? null
|
||||
: detail.isPending ? (
|
||||
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
|
||||
) : detail.isError ? (
|
||||
<EmptyState icon="alert" title="Could not load this candidate">
|
||||
{friendlyAuthError(detail.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
) : !live ? (
|
||||
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Candidate Profile"
|
||||
|
|
@ -35,11 +164,12 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
footer={
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
|
||||
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
onClick={() => onToggleFav(c)}
|
||||
disabled={isLive && (setFavorite.isPending || !live)}
|
||||
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
|
||||
>
|
||||
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
|
||||
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
|
|
@ -56,10 +186,10 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{c.name}</div>
|
||||
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
|
||||
<div className="ph-name">{live?.name || c.name}</div>
|
||||
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
|
||||
<div className="ph-tags">
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
|
||||
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,32 +200,76 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (
|
||||
{tab === 'Overview' && (guard || (live ? (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
|
||||
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
|
||||
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
|
||||
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
|
||||
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
|
||||
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
|
||||
<div className="info-item"><div className="il">Rating</div><div className="iv">⭐ {c.rating} / 5.0</div></div>
|
||||
<Info label="Email" val={live.email} />
|
||||
<Info label="Phone" val={live.phone} />
|
||||
<Info label="Applied For" val={live.job_title} />
|
||||
<Info label="Current Company" val={live.currentCompany} />
|
||||
<Info label="Experience" val={live.experience} />
|
||||
<Info label="Education" val={live.education} />
|
||||
<Info label="Source" val={live.source} />
|
||||
<Info label="Recruiter" val={live.recruiter} />
|
||||
<Info label="Applied On" val={fmtWhen(live.applied)} />
|
||||
<Info label="Screened On" val={fmtWhen(live.matched_at)} />
|
||||
<Info label="Rating" val={`⭐ ${(live.rating ?? 0).toFixed(1)} / 5.0`} />
|
||||
<Info label="Applications" val={live.job_posts?.length || 0} />
|
||||
</div>
|
||||
|
||||
{(live.match_summary || live.match_reasoning) && (
|
||||
<>
|
||||
<div style={LABEL}>AI Screening</div>
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
|
||||
<div className="card-body">
|
||||
{live.match_summary && <p style={{ marginBottom: live.match_reasoning ? 10 : 0 }}>{live.match_summary}</p>}
|
||||
{live.match_reasoning && <p className="text-muted text-sm">{live.match_reasoning}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{live.job_posts?.length > 0 && (
|
||||
<>
|
||||
<div style={LABEL}>Suggested Roles</div>
|
||||
<div className="k-tags">
|
||||
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<Info label="Email" val={c.email} />
|
||||
<Info label="Phone" val={c.phone} />
|
||||
<Info label="Location" val={c.location} />
|
||||
<Info label="Applied For" val={c.jobTitle} />
|
||||
<Info label="Current Company" val={c.currentCompany} />
|
||||
<Info label="Experience" val={`${c.experience} years`} />
|
||||
<Info label="Education" val={c.education} />
|
||||
<Info label="Source" val={c.source} />
|
||||
<Info label="Recruiter" val={c.recruiter} />
|
||||
<Info label="Applied On" val={fmtWhen(c.applied)} />
|
||||
<Info label="Expected Salary" val={moneyK(c.salary)} />
|
||||
<Info label="Rating" val={`⭐ ${c.rating} / 5.0`} />
|
||||
</div>
|
||||
<div style={LABEL}>Skills</div>
|
||||
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Resume' && (
|
||||
{tab === 'Resume' && (guard || (live ? (
|
||||
<ResumeTab live={live} />
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
|
|
@ -125,9 +299,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<Icon name="download" /> Download PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Timeline' && (
|
||||
{tab === 'Timeline' && (guard || (live ? (
|
||||
<TimelineTab live={live} />
|
||||
) : (
|
||||
<div className="timeline">
|
||||
{[
|
||||
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
|
||||
|
|
@ -144,9 +320,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Interview' && (
|
||||
{tab === 'Interview' && (guard || (live ? (
|
||||
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
|
||||
) : (
|
||||
candidateInterviews.length ? (
|
||||
<div className="list-tight">
|
||||
{candidateInterviews.map((iv) => (
|
||||
|
|
@ -167,9 +345,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
Schedule an interview to get started.
|
||||
</EmptyState>
|
||||
)
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Notes' && (
|
||||
{tab === 'Notes' && (guard || (live ? (
|
||||
<NotesTab userId={c.userId} rows={live.notes ?? []} />
|
||||
) : (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Add a note</label>
|
||||
|
|
@ -201,9 +381,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Activity' && (
|
||||
{tab === 'Activity' && (guard || (live ? (
|
||||
<ActivityTab userId={c.userId} inboxId={inboxId} rows={live.activity ?? []} />
|
||||
) : (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
|
||||
|
|
@ -222,9 +404,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Documents' && (
|
||||
{tab === 'Documents' && (guard || (live ? (
|
||||
<DocumentsTab rows={live.documents ?? []} />
|
||||
) : (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
|
||||
|
|
@ -241,9 +425,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)))}
|
||||
|
||||
{tab === 'Feedback' && (
|
||||
{tab === 'Feedback' && (guard || (live ? (
|
||||
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
|
||||
) : (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
|
||||
|
|
@ -270,8 +456,433 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
<Icon name="plus" /> Submit Scorecard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Live tabs. Each owns its own form state and its own write, so a half-typed
|
||||
note is not held by the modal shell and does not survive a tab switch.
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
function ResumeTab({ live }) {
|
||||
const source = live.documents?.[0]?.name
|
||||
if (!live.resume_text) {
|
||||
return (
|
||||
<EmptyState icon="file" title="No résumé text">
|
||||
{source
|
||||
? `${source} is attached but has not been parsed yet — run the match to extract it.`
|
||||
: 'This candidate applied without an attachment we could read.'}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
<h3 style={{ marginBottom: 4 }}>{live.name}</h3>
|
||||
<p className="text-muted">
|
||||
{source ? `Extracted from ${source}` : 'Extracted from the application email'}
|
||||
</p>
|
||||
<div className="divider" />
|
||||
<div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineTab({ live }) {
|
||||
const events = useMemo(() => {
|
||||
const out = []
|
||||
if (live.applied) {
|
||||
out.push({
|
||||
icon: 'user-plus',
|
||||
title: 'Application received',
|
||||
at: live.applied,
|
||||
desc: live.source ? `Applied via ${live.source}` : null,
|
||||
})
|
||||
}
|
||||
if (live.matched_at) {
|
||||
out.push({
|
||||
icon: 'sparkles',
|
||||
title: 'AI screening completed',
|
||||
at: live.matched_at,
|
||||
desc: live.match_error || live.match_summary || live.match_status,
|
||||
})
|
||||
}
|
||||
for (const iv of live.interviews ?? []) {
|
||||
out.push({
|
||||
icon: 'calendar',
|
||||
title: iv.interview_type || 'Interview',
|
||||
at: iv.interview_date,
|
||||
desc: iv.interview_status,
|
||||
})
|
||||
}
|
||||
for (const a of live.activity ?? []) {
|
||||
out.push({
|
||||
icon: 'zap',
|
||||
title: a.activity_type || 'Activity',
|
||||
at: a.activity_date,
|
||||
desc: a.description || a.activity_status,
|
||||
})
|
||||
}
|
||||
for (const f of live.feedback ?? []) {
|
||||
out.push({
|
||||
icon: 'star',
|
||||
title: f.review ? `Feedback: ${f.review}` : 'Feedback submitted',
|
||||
at: f.created_at,
|
||||
desc: f.reviewed_by_name ? `by ${f.reviewed_by_name}` : f.note,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => stamp(a.at) - stamp(b.at))
|
||||
}, [live])
|
||||
|
||||
if (!events.length) {
|
||||
return <EmptyState icon="clock" title="Nothing recorded yet">Activity appears here as the candidate moves.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timeline">
|
||||
{events.map((e, i) => (
|
||||
<div className="tl-item" key={`${e.title}-${i}`}>
|
||||
<div className="tl-dot"><Icon name={e.icon} /></div>
|
||||
<div className="tl-title">{e.title}</div>
|
||||
<div className="tl-meta">{fmtWhen(e.at)}</div>
|
||||
{e.desc && <div className="tl-desc">{e.desc}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterviewTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
// interviews.interview_date and .interview_time are BOTH datetime columns,
|
||||
// so the same instant goes to each rather than inventing a second one.
|
||||
mutationFn: (instant) => candidatesApi.createInterview({
|
||||
inboxId, date: instant, time: instant, type: form.type, status: form.status,
|
||||
}),
|
||||
success: 'Interview scheduled',
|
||||
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const instant = toInstant(form.date, form.time)
|
||||
if (!instant) {
|
||||
toast('Pick a date for the interview', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate(instant)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((iv) => {
|
||||
const clock = fmtClock(iv.interview_time)
|
||||
return (
|
||||
<div className="list-row" key={iv.id}>
|
||||
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="calendar" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.interview_type || 'Interview'}</div>
|
||||
<div className="lr-sub">{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
{iv.interview_status ? <Badge>{iv.interview_status}</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="calendar" title="No interviews scheduled">
|
||||
Schedule the first round below.
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
|
||||
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Status</label>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
|
||||
{INTERVIEW_STATES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Date</label>
|
||||
<input type="date" value={form.date} onChange={(e) => set('date', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Time</label>
|
||||
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesTab({ userId, rows }) {
|
||||
const [text, setText] = useState('')
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
|
||||
success: 'Note saved',
|
||||
onDone: () => setText(''),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Add a note</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Write a private note about this candidate…"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ margin: '10px 0 18px' }}
|
||||
disabled={!text.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}
|
||||
</button>
|
||||
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((n) => (
|
||||
<div className="list-row" key={n.id}>
|
||||
<Avatar name={n.created_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
|
||||
<div className="lr-sub">{fmtWhen(n.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createActivity({
|
||||
inboxId, type: form.type, status: 'Logged', description: form.description.trim(),
|
||||
}),
|
||||
success: 'Activity logged',
|
||||
onDone: () => setForm({ type: ACTIVITY_TYPES[0], description: '' }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (!form.description.trim()) {
|
||||
toast('Describe what happened', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((a) => {
|
||||
const clock = fmtClock(a.activity_time)
|
||||
return (
|
||||
<div className="list-row" key={a.id}>
|
||||
<span className="kpi-icn i-purple" style={{ width: 34, height: 34, borderRadius: 9 }}>
|
||||
<Icon name="zap" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{a.activity_type || 'Activity'}</div>
|
||||
{a.description && (
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.description}</div>
|
||||
)}
|
||||
<div className="lr-sub">{fmtWhen(a.activity_date)}{clock ? ` · ${clock}` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">{a.activity_status ? <Badge>{a.activity_status}</Badge> : null}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="zap" title="No activity recorded">Log the first touchpoint below.</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Log activity</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
|
||||
{ACTIVITY_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>What happened</label>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
placeholder="Called to confirm availability"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentsTab({ rows }) {
|
||||
if (!rows.length) {
|
||||
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{rows.map((d, i) => (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{d.name}</div>
|
||||
<div className="lr-sub">{d.path || 'Stored with the application'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* No download button: attachments live on the worker's filesystem and no
|
||||
route serves them yet, so a button here could only lie. */}
|
||||
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
|
||||
<Icon name="info" /> Attachments are stored server-side; download is not exposed yet.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const create = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.createFeedback({
|
||||
inboxId,
|
||||
review: form.review,
|
||||
score: form.score === '' ? 0 : Number(form.score),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
success: 'Scorecard submitted',
|
||||
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const score = form.score === '' ? 0 : Number(form.score)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
||||
toast('Score must be between 0 and 100', 'warning')
|
||||
return
|
||||
}
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((f) => (
|
||||
<div className="list-row" key={f.id}>
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
||||
<div className="lr-sub">{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">{f.review ? <Badge>{f.review}</Badge> : null}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Recommendation</label>
|
||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Score (0–100)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.score}
|
||||
onChange={(e) => set('score', e.target.value)}
|
||||
placeholder="80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
placeholder="What stood out, and what would you probe next round?"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ marginTop: 10 }}
|
||||
disabled={!inboxId || create.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@
|
|||
|
||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||
/candidates, which stopped resolving once the ids became real user_ids.
|
||||
|
||||
The card is seed-overlaid, but the MODAL is not: it re-reads the candidate by
|
||||
`userId` through GET /candidate/fetch?user_id=, which is a far richer payload
|
||||
than the list rows — résumé text, the agent's verdict, documents, and the
|
||||
interviews / notes / activity / feedback collections, all writable from their
|
||||
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
||||
whole trigger.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -52,10 +59,10 @@ function years(value) {
|
|||
/**
|
||||
* One API record overlaid on one seed candidate.
|
||||
*
|
||||
* `id` deliberately stays the SEED id: CandidateProfile joins seed interviews on
|
||||
* c.id and the favourite/advance mutations key off it, so a UUID here would
|
||||
* empty the Interview tab and silently drop those writes. The real identifier
|
||||
* rides along on `userId`.
|
||||
* `id` deliberately stays the SEED id: the favourite/advance seed mutations key
|
||||
* off it, so a UUID here would silently drop those writes. The real identifier
|
||||
* rides along on `userId`, and that is what the profile modal reads its live
|
||||
* record with.
|
||||
*/
|
||||
function merge(row, template) {
|
||||
const name = row.name || template.name
|
||||
|
|
@ -125,6 +132,8 @@ export default function TalentPool() {
|
|||
|
||||
// Both mirror Candidates.jsx so a change made here shows up there too. The
|
||||
// card renders neither favourite nor stage, so only the open modal restates.
|
||||
// Favourite is a real PATCH once the modal holds a userId; this seed path is
|
||||
// the fallback for inbox rows that were never linked to a user.
|
||||
function toggleFav(c) {
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
|
||||
|
|
|
|||
Loading…
Reference in New Issue