400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import type {
|
|
CheckpointReplyMetadata,
|
|
ChatMessageMeta,
|
|
RecruitmentProposalEntry,
|
|
StaffingEmployeeOption,
|
|
StaffingRoleEntry,
|
|
StaffingSelectionValue,
|
|
StaffingTemplateOption,
|
|
} from '../types/chat'
|
|
import type { TaskPreferredAgent } from '../types/kanban'
|
|
|
|
const TASK_AGENT_LABELS: Record<TaskPreferredAgent, string> = {
|
|
native: 'OpenOPC Native',
|
|
codex: 'Codex',
|
|
claude_code: 'Claude Code',
|
|
cursor: 'Cursor',
|
|
opencode: 'OpenCode',
|
|
}
|
|
|
|
const DEFAULT_ROLE_AGENT: TaskPreferredAgent = 'codex'
|
|
const DEFAULT_RECRUITMENT_AGENT: TaskPreferredAgent = 'native'
|
|
const TASK_AGENT_OPTIONS: TaskPreferredAgent[] = ['codex', 'native', 'claude_code', 'cursor', 'opencode']
|
|
const RECRUITMENT_AGENT_OPTIONS: TaskPreferredAgent[] = ['native', 'codex', 'claude_code', 'cursor', 'opencode']
|
|
|
|
type StaffingOption =
|
|
| { kind: 'employee'; id: string; name: string; subtitle: string; category: string; searchText: string }
|
|
| { kind: 'template'; id: string; name: string; subtitle: string; category: string; searchText: string }
|
|
| { kind: 'fallback'; id: ''; name: string; subtitle: string; category: string; searchText: string }
|
|
|
|
interface RecruitmentPanelProps {
|
|
meta: ChatMessageMeta
|
|
onReply: (text: string, metadata?: CheckpointReplyMetadata) => void
|
|
responded: boolean
|
|
}
|
|
|
|
function normalizeSelection(value: StaffingSelectionValue | undefined): StaffingSelectionValue {
|
|
if (!value) return { kind: 'fallback' }
|
|
if (value.kind === 'employee') {
|
|
const id = String(value.id ?? value.employee_id ?? '').trim()
|
|
return id ? { kind: 'employee', id } : { kind: 'fallback' }
|
|
}
|
|
if (value.kind === 'template') {
|
|
const id = String(value.id ?? value.template_id ?? '').trim()
|
|
return id ? { kind: 'template', id } : { kind: 'fallback' }
|
|
}
|
|
return { kind: 'fallback' }
|
|
}
|
|
|
|
function selectionKey(value: StaffingSelectionValue | undefined): string {
|
|
const normalized = normalizeSelection(value)
|
|
return normalized.kind === 'fallback' ? 'fallback:' : `${normalized.kind}:${normalized.id ?? ''}`
|
|
}
|
|
|
|
function buildOptions(
|
|
role: StaffingRoleEntry,
|
|
employees: StaffingEmployeeOption[],
|
|
templates: StaffingTemplateOption[],
|
|
): StaffingOption[] {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
const roleLabel = String(role.role_label ?? '').trim()
|
|
const roleText = `${roleId} ${roleLabel} ${role.role_responsibility ?? ''}`.toLowerCase()
|
|
const sameRoleIds = new Set((role.same_role_employee_ids ?? []).map(item => String(item ?? '').trim()).filter(Boolean))
|
|
const employeeOptions = employees.map((employee): StaffingOption & { rank: number } => {
|
|
const id = String(employee.employee_id ?? '').trim()
|
|
const name = String(employee.employee_name ?? id).trim() || id
|
|
const employeeRole = String(employee.role_id ?? '').trim()
|
|
const category = String(employee.category ?? '').trim()
|
|
const subtitle = [employeeRole, category].filter(Boolean).join(' · ') || id
|
|
return {
|
|
kind: 'employee',
|
|
id,
|
|
name,
|
|
subtitle,
|
|
category,
|
|
searchText: `${id} ${name} ${employeeRole} ${category} ${(employee.domains ?? []).join(' ')} ${(employee.tags ?? []).join(' ')}`.toLowerCase(),
|
|
rank: sameRoleIds.has(id) || employeeRole === roleId ? 0 : 2,
|
|
}
|
|
}).filter(option => option.id).sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
|
|
const templateOptions = templates.map((template): StaffingOption & { rank: number } => {
|
|
const id = String(template.template_id ?? '').trim()
|
|
const name = String(template.template_name ?? id).trim() || id
|
|
const category = String(template.category ?? '').trim()
|
|
const subtitle = [category, id].filter(Boolean).join(' · ') || id
|
|
const templateText = `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
|
|
const rank = roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3).reduce(
|
|
(score, token) => score + (templateText.includes(token) ? 1 : 0),
|
|
0,
|
|
)
|
|
return {
|
|
kind: 'template',
|
|
id,
|
|
name,
|
|
subtitle,
|
|
category,
|
|
searchText: templateText,
|
|
rank,
|
|
}
|
|
}).filter(option => option.id).sort((a, b) => b.rank - a.rank || a.name.localeCompare(b.name))
|
|
return [
|
|
...employeeOptions,
|
|
...templateOptions,
|
|
{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' },
|
|
]
|
|
}
|
|
|
|
function optionForSelection(options: StaffingOption[], selection: StaffingSelectionValue | undefined): StaffingOption {
|
|
const key = selectionKey(selection)
|
|
return options.find(option => `${option.kind}:${option.id}` === key) ?? options[0]
|
|
}
|
|
|
|
function optionMatches(option: StaffingOption, query: string): boolean {
|
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
if (terms.length === 0) return true
|
|
return terms.every(term => option.searchText.includes(term))
|
|
}
|
|
|
|
function buildRolesFromProposals(proposals: RecruitmentProposalEntry[]): StaffingRoleEntry[] {
|
|
return proposals.map((proposal) => {
|
|
const roleId = String(proposal.role_id ?? '').trim()
|
|
const existingId = String(proposal.existing_employee?.employee_id ?? '').trim()
|
|
const templateId = String(proposal.candidate?.template_id ?? '').trim()
|
|
const defaultSelection: StaffingSelectionValue = existingId
|
|
? { kind: 'employee', id: existingId }
|
|
: templateId
|
|
? { kind: 'template', id: templateId }
|
|
: { kind: 'fallback' }
|
|
return {
|
|
role_id: roleId,
|
|
role_label: proposal.role_labels?.[0] ?? roleId,
|
|
role_responsibility: '',
|
|
default_selection: defaultSelection,
|
|
same_role_employee_ids: proposal.existing_employee_ids ?? [],
|
|
fallback_available: true,
|
|
default_agent: proposal.default_agent ?? DEFAULT_ROLE_AGENT,
|
|
selected_agent: proposal.selected_agent ?? proposal.default_agent ?? DEFAULT_ROLE_AGENT,
|
|
default_source: 'recruitment',
|
|
}
|
|
}).filter(role => role.role_id)
|
|
}
|
|
|
|
function selectedRecruitmentName(
|
|
proposal: RecruitmentProposalEntry | undefined,
|
|
selected: StaffingOption,
|
|
): string {
|
|
if (selected.kind === 'template' && proposal?.candidate?.template_id === selected.id) {
|
|
return proposal.candidate.proposed_name || proposal.candidate.template_name || selected.name
|
|
}
|
|
if (selected.kind === 'employee' && proposal?.existing_employee?.employee_id === selected.id) {
|
|
return proposal.existing_employee.employee_name || selected.name
|
|
}
|
|
return selected.name
|
|
}
|
|
|
|
function buildSelectionsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, StaffingSelectionValue> {
|
|
const initial: Record<string, StaffingSelectionValue> = {}
|
|
const persisted = meta.staffing_selections ?? {}
|
|
for (const role of roles) {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
if (!roleId) continue
|
|
initial[roleId] = normalizeSelection(persisted[roleId] ?? role.default_selection)
|
|
}
|
|
return initial
|
|
}
|
|
|
|
function buildRoleAgentsFromMeta(meta: ChatMessageMeta, roles: StaffingRoleEntry[]): Record<string, TaskPreferredAgent> {
|
|
const persisted = meta.recruitment_role_agents ?? {}
|
|
const initial: Record<string, TaskPreferredAgent> = {}
|
|
for (const role of roles) {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
if (!roleId) continue
|
|
initial[roleId] = persisted[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
|
|
}
|
|
return initial
|
|
}
|
|
|
|
function hasSubmittedCheckpointMetadata(meta: ChatMessageMeta): boolean {
|
|
return Boolean(
|
|
String(meta.checkpoint_response_message_id ?? '').trim()
|
|
|| String(meta.checkpoint_responded_at ?? '').trim()
|
|
|| String(meta.checkpoint_reply_kind ?? '').trim()
|
|
)
|
|
}
|
|
|
|
export const RecruitmentPanel = React.memo(function RecruitmentPanel({
|
|
meta, onReply, responded,
|
|
}: RecruitmentPanelProps) {
|
|
const proposals = meta.proposals ?? []
|
|
const proposalByRole = useMemo(() => {
|
|
const next: Record<string, RecruitmentProposalEntry> = {}
|
|
for (const proposal of proposals) {
|
|
const roleId = String(proposal.role_id ?? '').trim()
|
|
if (roleId) next[roleId] = proposal
|
|
}
|
|
return next
|
|
}, [proposals])
|
|
const roles = useMemo(
|
|
() => (meta.staffing_roles?.length ? meta.staffing_roles : buildRolesFromProposals(proposals)),
|
|
[meta.staffing_roles, proposals],
|
|
)
|
|
const employees = meta.staffing_pool?.employees ?? []
|
|
const templates = meta.staffing_pool?.templates ?? []
|
|
const rationales = meta.recruitment_rationales ?? []
|
|
const optionsByRole = useMemo(() => {
|
|
const next: Record<string, StaffingOption[]> = {}
|
|
for (const role of roles) {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
if (roleId) next[roleId] = buildOptions(role, employees, templates)
|
|
}
|
|
return next
|
|
}, [employees, roles, templates])
|
|
const [queries, setQueries] = useState<Record<string, string>>({})
|
|
const [feedback, setFeedback] = useState('')
|
|
const [selections, setSelections] = useState<Record<string, StaffingSelectionValue>>(() => buildSelectionsFromMeta(meta, roles))
|
|
const [roleAgents, setRoleAgents] = useState<Record<string, TaskPreferredAgent>>(() => buildRoleAgentsFromMeta(meta, roles))
|
|
const [recruitmentAgent, setRecruitmentAgent] = useState<TaskPreferredAgent>(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
|
const isResponded = responded
|
|
|
|
useEffect(() => {
|
|
if (!isResponded || !hasSubmittedCheckpointMetadata(meta)) return
|
|
setSelections(buildSelectionsFromMeta(meta, roles))
|
|
setRoleAgents(buildRoleAgentsFromMeta(meta, roles))
|
|
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
|
}, [isResponded, meta, roles])
|
|
|
|
useEffect(() => {
|
|
setRecruitmentAgent(meta.recruitment_agent ?? DEFAULT_RECRUITMENT_AGENT)
|
|
}, [meta.recruitment_agent])
|
|
|
|
const buildReplyMetadata = useCallback((kind: NonNullable<CheckpointReplyMetadata['checkpoint_reply_kind']>): CheckpointReplyMetadata => {
|
|
const checkpointId = String(meta.checkpoint_id ?? '').trim()
|
|
if (!checkpointId) {
|
|
throw new Error('Recruitment checkpoint reply requires checkpoint_id metadata.')
|
|
}
|
|
const checkpointType = String(meta.checkpoint_type ?? '').trim()
|
|
return {
|
|
response_to_checkpoint_id: checkpointId,
|
|
response_to_checkpoint_type: checkpointType || 'company_recruitment_confirmation',
|
|
checkpoint_reply_kind: kind,
|
|
staffing_selections: selections,
|
|
recruitment_agent: recruitmentAgent,
|
|
recruitment_role_agents: roles.reduce<Record<string, TaskPreferredAgent>>((acc, role) => {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
if (!roleId) return acc
|
|
acc[roleId] = roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT
|
|
return acc
|
|
}, {}),
|
|
}
|
|
}, [meta.checkpoint_id, meta.checkpoint_type, recruitmentAgent, roleAgents, roles, selections])
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (isResponded) return
|
|
onReply('approve', buildReplyMetadata('approve'))
|
|
}, [buildReplyMetadata, isResponded, onReply])
|
|
|
|
const handleFeedback = useCallback(() => {
|
|
if (isResponded || !feedback.trim()) return
|
|
onReply(feedback.trim(), buildReplyMetadata('feedback'))
|
|
setFeedback('')
|
|
}, [buildReplyMetadata, isResponded, feedback, onReply])
|
|
|
|
return (
|
|
<div className="ckpt-panel ckpt-recruitment">
|
|
<div className="ckpt-header">
|
|
<div className="ckpt-icon ckpt-icon-recruit">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="6" cy="5" r="3" />
|
|
<path d="M2 14c0-2.2 1.8-4 4-4s4 1.8 4 4" />
|
|
<path d="M12 5v4M10 7h4" />
|
|
</svg>
|
|
</div>
|
|
<div className="ckpt-title">Recruitment Review</div>
|
|
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
|
|
{isResponded && <span className="ckpt-badge ckpt-badge-responded">Responded</span>}
|
|
</div>
|
|
|
|
{meta.summary && <div className="ckpt-summary">{meta.summary}</div>}
|
|
|
|
<div className="ckpt-recruiter-agent">
|
|
<label className="ckpt-agent-label" htmlFor={`recruitment-recruiter-agent-${meta.checkpoint_id}`}>
|
|
Recruiter Agent
|
|
</label>
|
|
<select
|
|
id={`recruitment-recruiter-agent-${meta.checkpoint_id}`}
|
|
className="ckpt-agent-select"
|
|
value={recruitmentAgent}
|
|
onChange={event => setRecruitmentAgent(event.target.value as TaskPreferredAgent)}
|
|
disabled={isResponded}
|
|
>
|
|
{RECRUITMENT_AGENT_OPTIONS.map(agent => (
|
|
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{rationales.length > 0 && (
|
|
<div className="ckpt-recruitment-reasons">
|
|
{rationales.map(item => (
|
|
<div key={item.role_id} className="ckpt-recruitment-reason">
|
|
<div className="ckpt-proposal-header">
|
|
<span className="ckpt-role-name">{item.role_id}</span>
|
|
{item.role_label && item.role_label !== item.role_id && <span className="ckpt-field-tag">{item.role_label}</span>}
|
|
{item.selection_label && <span className="ckpt-badge ckpt-badge-template">{item.selection_label}</span>}
|
|
</div>
|
|
{item.rationale && <div className="ckpt-rationale">{item.rationale}</div>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="ckpt-staffing-grid">
|
|
{roles.map(role => {
|
|
const roleId = String(role.role_id ?? '').trim()
|
|
const options = optionsByRole[roleId] ?? [{ kind: 'fallback', id: '', name: 'Fallback role-only', subtitle: 'No employee override', category: 'fallback', searchText: 'fallback role only no employee override' }]
|
|
const selected = optionForSelection(options, selections[roleId])
|
|
const proposal = proposalByRole[roleId]
|
|
const query = queries[roleId] ?? ''
|
|
const visibleOptions = options.filter(option => optionMatches(option, query)).slice(0, 8)
|
|
return (
|
|
<div key={roleId} className="ckpt-staffing-card">
|
|
<div className="ckpt-proposal-header">
|
|
<span className="ckpt-role-name">{roleId}</span>
|
|
<span className={`ckpt-badge ckpt-badge-${selected.kind}`}>{selected.kind}</span>
|
|
</div>
|
|
{role.role_label && role.role_label !== roleId && (
|
|
<div className="ckpt-role-labels">
|
|
<span className="ckpt-field-tag">{role.role_label}</span>
|
|
</div>
|
|
)}
|
|
<div className="ckpt-staffing-selected">
|
|
<div className="ckpt-cand-name">{selectedRecruitmentName(proposal, selected)}</div>
|
|
<div className="ckpt-cand-meta">
|
|
<span className="ckpt-cand-category">{selected.category}</span>
|
|
<span className="ckpt-domain-tag">{selected.subtitle}</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
className="ckpt-staffing-search"
|
|
value={query}
|
|
onChange={event => setQueries(current => ({ ...current, [roleId]: event.target.value }))}
|
|
placeholder="Search employees or templates..."
|
|
disabled={isResponded}
|
|
/>
|
|
<div className="ckpt-staffing-options">
|
|
{visibleOptions.map(option => {
|
|
const active = `${option.kind}:${option.id}` === selectionKey(selections[roleId])
|
|
return (
|
|
<button
|
|
key={`${option.kind}:${option.id}`}
|
|
className={`ckpt-staffing-option${active ? ' active' : ''}`}
|
|
onClick={() => setSelections(current => ({ ...current, [roleId]: { kind: option.kind, id: option.id } }))}
|
|
disabled={isResponded}
|
|
title={option.subtitle}
|
|
>
|
|
<span className="ckpt-staffing-option-kind">{option.kind}</span>
|
|
<span className="ckpt-staffing-option-name">{option.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="ckpt-agent-picker">
|
|
<label className="ckpt-agent-label" htmlFor={`recruit-agent-${meta.checkpoint_id}-${roleId}`}>
|
|
Execution Agent
|
|
</label>
|
|
<select
|
|
id={`recruit-agent-${meta.checkpoint_id}-${roleId}`}
|
|
className="ckpt-agent-select"
|
|
value={roleAgents[roleId] ?? role.selected_agent ?? role.default_agent ?? DEFAULT_ROLE_AGENT}
|
|
onChange={event => setRoleAgents(current => ({ ...current, [roleId]: event.target.value as TaskPreferredAgent }))}
|
|
disabled={isResponded}
|
|
>
|
|
{TASK_AGENT_OPTIONS.map(agent => (
|
|
<option key={agent} value={agent}>{TASK_AGENT_LABELS[agent]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{!isResponded && (
|
|
<div className="ckpt-actions ckpt-actions-inline-feedback">
|
|
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve</button>
|
|
<textarea
|
|
className="ckpt-feedback-input ckpt-feedback-inline-input"
|
|
placeholder="Feedback to refine recruitment..."
|
|
value={feedback}
|
|
onChange={e => setFeedback(e.target.value)}
|
|
rows={2}
|
|
/>
|
|
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleFeedback} disabled={!feedback.trim()}>
|
|
Send Feedback
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|