Files
2026-07-01 17:56:31 +08:00

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