337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import type {
|
|
CheckpointReplyMetadata,
|
|
ChatMessageMeta,
|
|
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 StaffingSelectionPanelProps {
|
|
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 templateScore = (template: StaffingTemplateOption): number => {
|
|
const templateText = `${template.template_id ?? ''} ${template.template_name ?? ''} ${template.category ?? ''} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase()
|
|
const categoryTerm = String(template.category ?? '').toLowerCase()
|
|
let score = 0
|
|
for (const token of roleText.split(/[^a-z0-9]+/).filter(token => token.length >= 3)) {
|
|
if (templateText.includes(token) || (categoryTerm && token.includes(categoryTerm))) score += 1
|
|
}
|
|
if (templateText.includes(roleId.replace(/_/g, '-')) || templateText.includes(roleId.replace(/_/g, ' '))) score += 2
|
|
return score
|
|
}
|
|
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
|
|
return {
|
|
kind: 'template',
|
|
id,
|
|
name,
|
|
subtitle,
|
|
category,
|
|
searchText: `${id} ${name} ${category} ${(template.domains ?? []).join(' ')} ${(template.tags ?? []).join(' ')}`.toLowerCase(),
|
|
rank: templateScore(template),
|
|
}
|
|
}).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 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.staffing_action ?? '').trim()
|
|
)
|
|
}
|
|
|
|
export const StaffingSelectionPanel = React.memo(function StaffingSelectionPanel({
|
|
meta, onReply, responded,
|
|
}: StaffingSelectionPanelProps) {
|
|
const roles = useMemo(() => meta.staffing_roles ?? [], [meta.staffing_roles])
|
|
const employees = meta.staffing_pool?.employees ?? []
|
|
const templates = meta.staffing_pool?.templates ?? []
|
|
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 [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
|
|
const recommendAutoRecruit = meta.recommended_action === 'auto_recruit' && templates.length > 0
|
|
|
|
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((action: 'manual_approve' | 'auto_recruit'): CheckpointReplyMetadata => {
|
|
const checkpointId = String(meta.checkpoint_id ?? '').trim()
|
|
if (!checkpointId) {
|
|
throw new Error('Staffing checkpoint reply requires checkpoint_id metadata.')
|
|
}
|
|
return {
|
|
response_to_checkpoint_id: checkpointId,
|
|
response_to_checkpoint_type: 'company_staffing_selection',
|
|
staffing_action: action,
|
|
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, recruitmentAgent, roleAgents, roles, selections])
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (isResponded) return
|
|
onReply('approve', buildReplyMetadata('manual_approve'))
|
|
}, [buildReplyMetadata, isResponded, onReply])
|
|
|
|
const handleAutoRecruit = useCallback(() => {
|
|
if (isResponded) return
|
|
onReply('auto recruit', buildReplyMetadata('auto_recruit'))
|
|
}, [buildReplyMetadata, isResponded, onReply])
|
|
|
|
return (
|
|
<div className="ckpt-panel ckpt-staffing">
|
|
<div className="ckpt-header">
|
|
<div className="ckpt-icon ckpt-icon-staffing">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
|
<path d="M1.5 14c.6-2.5 2.2-4 4.5-4s3.9 1.5 4.5 4" />
|
|
<path d="M12.5 3.5v5M10 6h5" />
|
|
</svg>
|
|
</div>
|
|
<div className="ckpt-title">Manual Staffing</div>
|
|
<span className="ckpt-badge ckpt-badge-profile">{meta.company_profile || 'corporate'}</span>
|
|
{recommendAutoRecruit && !isResponded && <span className="ckpt-badge ckpt-badge-scope">Recruit recommended</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={`staffing-recruiter-agent-${meta.checkpoint_id}`}>
|
|
Recruiter Agent
|
|
</label>
|
|
<select
|
|
id={`staffing-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>
|
|
|
|
<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 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">{selected.name}</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={`staffing-agent-${meta.checkpoint_id}-${roleId}`}>
|
|
Execution Agent
|
|
</label>
|
|
<select
|
|
id={`staffing-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">
|
|
{recommendAutoRecruit ? (
|
|
<>
|
|
<button className="ckpt-btn ckpt-btn-approve" onClick={handleAutoRecruit}>Auto Recruit</button>
|
|
<button className="ckpt-btn ckpt-btn-feedback" onClick={handleApprove}>Approve Selections</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button className="ckpt-btn ckpt-btn-approve" onClick={handleApprove}>Approve Selections</button>
|
|
{templates.length > 0 && <button className="ckpt-btn ckpt-btn-feedback" onClick={handleAutoRecruit}>Auto Recruit</button>}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|