Initial commit - WhyRating Engine (Google Reviews Scraper)

This commit is contained in:
Alejandro Gutiérrez
2026-02-02 18:19:00 +00:00
parent 0543a08242
commit 2206ddeff2
136 changed files with 51138 additions and 855 deletions

View File

@@ -0,0 +1,127 @@
'use client';
import { taxonomy } from '@/lib/taxonomy/data';
const LAYER_COLORS = {
conditions: {
bg: 'bg-yellow-500/10',
border: 'border-yellow-500/30',
text: 'text-yellow-400',
badge: 'bg-yellow-500/20 text-yellow-300',
},
management: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/30',
text: 'text-blue-400',
badge: 'bg-blue-500/20 text-blue-300',
},
systemic: {
bg: 'bg-purple-500/10',
border: 'border-purple-500/30',
text: 'text-purple-400',
badge: 'bg-purple-500/20 text-purple-300',
},
};
const LAYER_TITLES = {
conditions: 'Conditions',
management: 'Management',
systemic: 'Systemic',
};
const LAYER_DESCRIPTIONS = {
conditions: 'What allowed the experience to happen?',
management: 'What decisions allowed enabling conditions?',
systemic: 'Why does the organization create these conditions?',
};
export default function CausalCodesSection() {
const layers = ['conditions', 'management', 'systemic'] as const;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Causal Codes</h2>
<p className="text-gray-400 mt-1">
Three-layer root cause analysis framework with 16 codes
</p>
</div>
{/* Three layers visualization */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{layers.map((layerKey) => {
const layer = taxonomy.causal_codes[layerKey];
const colors = LAYER_COLORS[layerKey];
const codeCount = Object.keys(layer.codes).length;
return (
<div
key={layerKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Layer Header */}
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold ${colors.text}`}>
{LAYER_TITLES[layerKey]}
</h3>
<span className={`text-xs px-2 py-0.5 rounded ${colors.badge}`}>
{layer.prefix}* ({codeCount})
</span>
</div>
<p className="text-sm text-gray-400 mb-4">
{LAYER_DESCRIPTIONS[layerKey]}
</p>
{/* Codes list */}
<div className="space-y-2">
{Object.entries(layer.codes).map(([codeKey, code]) => (
<div
key={codeKey}
className="p-2 bg-gray-800/50 rounded border border-gray-700/50"
>
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${colors.text}`}>
{codeKey}
</span>
<span className="text-sm text-gray-300">{code.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{code.definition}</p>
</div>
))}
</div>
</div>
);
})}
</div>
{/* Flow Indicator */}
<div className="flex items-center justify-center gap-4 py-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded">
Conditions
</span>
<span className="text-gray-600"></span>
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded">
Management
</span>
<span className="text-gray-600"></span>
<span className="px-3 py-1 bg-purple-500/20 text-purple-400 rounded">
Systemic
</span>
</div>
</div>
{/* Usage note */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
<p className="text-sm text-gray-400">
Causal codes are used for root cause analysis in URT-Full profile. Start with
Conditions (immediate factors), trace to Management (decisions that enabled conditions),
and finally to Systemic (organizational factors that created the management decisions).
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { taxonomy } from '@/lib/taxonomy/data';
const DIMENSION_COLORS: Record<string, { bg: string; border: string; text: string }> = {
valence: { bg: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-400' },
intensity: { bg: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-400' },
specificity: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-400' },
actionability: { bg: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-400' },
temporal: { bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-400' },
evidence: { bg: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-400' },
comparative: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
};
export default function MetadataSection() {
const dimensions = Object.entries(taxonomy.metadata_dimensions);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Metadata Dimensions</h2>
<p className="text-gray-400 mt-1">
7 dimensions with 24 values for enriching classifications
</p>
</div>
{/* Dimension Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{dimensions.map(([dimKey, dimension]) => {
const colors = DIMENSION_COLORS[dimKey] || {
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
text: 'text-gray-400',
};
const valueCount = Object.keys(dimension.values).length;
return (
<div
key={dimKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Dimension Header */}
<div className="flex items-center justify-between mb-2">
<h3 className={`font-semibold ${colors.text}`}>{dimension.name}</h3>
<span className="font-mono text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{dimension.code}
</span>
</div>
<p className="text-sm text-gray-400 mb-4">{dimension.description}</p>
{/* Values */}
<div className="space-y-2">
{Object.entries(dimension.values).map(([valueKey, value]) => (
<div
key={valueKey}
className="flex items-start gap-2 text-sm"
>
<span className={`font-mono ${colors.text} flex-shrink-0`}>
{valueKey}
</span>
<div className="flex-1">
<span className="text-gray-300">{value.label}</span>
{value.markers && value.markers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{value.markers.slice(0, 3).map((marker, idx) => (
<span
key={idx}
className="text-xs text-gray-500 bg-gray-800/50 px-1.5 py-0.5 rounded"
>
&quot;{marker}&quot;
</span>
))}
</div>
)}
{value.example && (
<p className="text-xs text-gray-500 mt-1 italic">
e.g., {value.example}
</p>
)}
</div>
</div>
))}
</div>
{/* Default indicator */}
{dimension.default && (
<div className="mt-3 pt-2 border-t border-gray-700/50">
<span className="text-xs text-gray-500">
Default: <span className="font-mono">{dimension.default}</span>
</span>
</div>
)}
</div>
);
})}
</div>
{/* Usage note */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
<p className="text-sm text-gray-400">
Metadata dimensions enrich each classification span. Required dimensions vary by profile:
URT-Lite requires only Valence, while URT-Full requires all 7 dimensions.
Dimensions like Comparative (CR) and Evidence (E) have defaults when not applicable.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import { Check, X, Minus } from 'lucide-react';
import { taxonomy } from '@/lib/taxonomy/data';
const PROFILE_COLORS: Record<string, { bg: string; border: string; text: string; badge: string }> = {
lite: {
bg: 'bg-green-500/10',
border: 'border-green-500/30',
text: 'text-green-400',
badge: 'bg-green-500',
},
core: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/30',
text: 'text-blue-400',
badge: 'bg-blue-500',
},
standard: {
bg: 'bg-purple-500/10',
border: 'border-purple-500/30',
text: 'text-purple-400',
badge: 'bg-purple-500',
},
full: {
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
text: 'text-amber-400',
badge: 'bg-amber-500',
},
};
const COMPLEXITY_COLORS: Record<string, string> = {
Minimal: 'text-green-400',
Low: 'text-blue-400',
Medium: 'text-purple-400',
High: 'text-amber-400',
};
const ALL_FIELDS = [
'primary_code',
'secondary_codes',
'valence',
'intensity',
'specificity',
'actionability',
'temporal',
'evidence',
'comparative',
'causal_chain',
'linked_spans',
'confidence',
'annotator_notes',
];
const FIELD_LABELS: Record<string, string> = {
primary_code: 'Primary Code',
secondary_codes: 'Secondary Codes',
valence: 'Valence',
intensity: 'Intensity',
specificity: 'Specificity',
actionability: 'Actionability',
temporal: 'Temporal',
evidence: 'Evidence',
comparative: 'Comparative',
causal_chain: 'Causal Chain',
linked_spans: 'Linked Spans',
confidence: 'Confidence',
annotator_notes: 'Annotator Notes',
};
export default function ProfilesSection() {
const profiles = Object.entries(taxonomy.profiles);
const getFieldStatus = (profile: typeof taxonomy.profiles.lite, field: string) => {
if (profile.required_fields.includes(field)) return 'required';
if (profile.optional_fields.includes(field)) return 'optional';
if (profile.forbidden_fields.includes(field)) return 'forbidden';
return 'forbidden';
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Implementation Profiles</h2>
<p className="text-gray-400 mt-1">
4 profiles for different implementation complexity levels
</p>
</div>
{/* Profile Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4">
{profiles.map(([profileKey, profile]) => {
const colors = PROFILE_COLORS[profileKey];
return (
<div
key={profileKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Profile Header */}
<div className="flex items-center justify-between mb-2">
<h3 className={`font-semibold ${colors.text}`}>{profile.name}</h3>
<span className={`w-3 h-3 rounded-full ${colors.badge}`} />
</div>
{/* Stats */}
<div className="flex items-center gap-3 mb-3 text-sm">
<span className="text-gray-300">{profile.code_count} codes</span>
<span className="text-gray-600">|</span>
<span className={COMPLEXITY_COLORS[profile.complexity]}>
{profile.complexity}
</span>
</div>
<p className="text-sm text-gray-400 mb-4">{profile.use_case}</p>
{/* Code pattern */}
<div className="mb-4">
<span className="text-xs text-gray-500">Code Level:</span>
<span className="ml-2 font-mono text-sm text-gray-300">
{profile.code_type}
</span>
</div>
{/* Pattern */}
<div className="bg-gray-800/50 rounded p-2 mb-3">
<span className="text-xs text-gray-500">Pattern:</span>
<code className="block font-mono text-xs text-gray-300 mt-1">
{profile.primary_code_pattern}
</code>
</div>
{/* Secondary codes */}
<div className="text-xs text-gray-400">
{profile.secondary_codes_allowed ? (
<span>
Up to {profile.secondary_codes_max} secondary codes (tier{' '}
{profile.secondary_codes_tier})
</span>
) : (
<span className="text-gray-500">No secondary codes</span>
)}
</div>
</div>
);
})}
</div>
{/* Field Comparison Table */}
<div className="mt-8">
<h3 className="text-lg font-semibold text-gray-200 mb-4">Field Requirements by Profile</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-3 text-gray-400 font-medium">Field</th>
{profiles.map(([profileKey, profile]) => (
<th
key={profileKey}
className={`text-center py-2 px-3 ${PROFILE_COLORS[profileKey].text} font-medium`}
>
{profile.name}
</th>
))}
</tr>
</thead>
<tbody>
{ALL_FIELDS.map((field) => (
<tr key={field} className="border-b border-gray-800 hover:bg-gray-800/30">
<td className="py-2 px-3 text-gray-300">{FIELD_LABELS[field]}</td>
{profiles.map(([profileKey, profile]) => {
const status = getFieldStatus(profile, field);
return (
<td key={profileKey} className="py-2 px-3 text-center">
{status === 'required' && (
<Check className="w-4 h-4 text-green-400 mx-auto" />
)}
{status === 'optional' && (
<Minus className="w-4 h-4 text-yellow-400 mx-auto" />
)}
{status === 'forbidden' && (
<X className="w-4 h-4 text-gray-600 mx-auto" />
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex items-center gap-6 mt-4 text-xs text-gray-400">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400" />
<span>Required</span>
</div>
<div className="flex items-center gap-2">
<Minus className="w-4 h-4 text-yellow-400" />
<span>Optional</span>
</div>
<div className="flex items-center gap-2">
<X className="w-4 h-4 text-gray-600" />
<span>Not used</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import type { SelectedSubcode } from '@/lib/taxonomy/types';
import { DOMAIN_TEXT_COLORS, DOMAIN_BG_COLORS, DOMAIN_BORDER_COLORS } from '@/lib/taxonomy/types';
interface SubcodeDetailProps {
selectedSubcode: SelectedSubcode | null;
}
export default function SubcodeDetail({ selectedSubcode }: SubcodeDetailProps) {
if (!selectedSubcode) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<p className="text-lg">Select a subcode</p>
<p className="text-sm mt-1">Click on a subcode to view its details</p>
</div>
</div>
);
}
const { code, domainKey, domainName, categoryName, subcode } = selectedSubcode;
const textColor = DOMAIN_TEXT_COLORS[domainKey];
const bgColor = DOMAIN_BG_COLORS[domainKey];
const borderColor = DOMAIN_BORDER_COLORS[domainKey];
return (
<div className="flex flex-col h-full overflow-y-auto">
{/* Header */}
<div className={`p-4 border-b border-gray-700 ${bgColor}`}>
<h2 className={`text-xl font-bold ${textColor}`}>
{code} {subcode.name}
</h2>
<div className="flex gap-2 mt-2 text-sm text-gray-400">
<span>Domain: {domainName}</span>
<span className="text-gray-600">|</span>
<span>Category: {categoryName}</span>
</div>
</div>
{/* Content */}
<div className="flex-1 p-4 space-y-6">
{/* Definition */}
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-2">
Definition
</h3>
<p className="text-gray-200">{subcode.definition}</p>
</section>
{/* Examples */}
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
Examples
</h3>
<div className="space-y-2">
{/* Positive Example */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div>
<span className="text-xs text-green-400 uppercase font-medium">Positive</span>
<p className="text-gray-200 mt-0.5">{subcode.positive_example}</p>
</div>
</div>
{/* Negative Example */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div>
<span className="text-xs text-red-400 uppercase font-medium">Negative</span>
<p className="text-gray-200 mt-0.5">{subcode.negative_example}</p>
</div>
</div>
</div>
</section>
{/* Don't Confuse With */}
{subcode.dont_confuse_with && (
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
Don&apos;t Confuse With
</h3>
<div className={`flex items-start gap-3 p-3 rounded-lg border ${bgColor} ${borderColor}`}>
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<span className={`font-mono font-medium ${textColor}`}>
{subcode.dont_confuse_with}
</span>
<p className="text-gray-300 mt-1 text-sm">{subcode.dont_confuse_reason}</p>
</div>
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { Search, X } from 'lucide-react';
import { useCallback, useState, useEffect } from 'react';
interface TaxonomySearchProps {
value: string;
onChange: (value: string) => void;
resultCount?: number;
}
export default function TaxonomySearch({ value, onChange, resultCount }: TaxonomySearchProps) {
const [localValue, setLocalValue] = useState(value);
// Debounce the search
useEffect(() => {
const timer = setTimeout(() => {
onChange(localValue);
}, 200);
return () => clearTimeout(timer);
}, [localValue, onChange]);
// Sync external changes
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleClear = useCallback(() => {
setLocalValue('');
onChange('');
}, [onChange]);
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder="Search codes..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-9 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
/>
{localValue && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{value && resultCount !== undefined && (
<div className="absolute right-0 top-full mt-1 text-xs text-gray-500">
{resultCount} {resultCount === 1 ? 'match' : 'matches'}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import TreeNode from './TreeNode';
import { taxonomy, getDomainSubcodeCount, getSubcodeCount } from '@/lib/taxonomy/data';
import type { SelectedSubcode } from '@/lib/taxonomy/types';
interface TaxonomyTreeProps {
searchQuery: string;
searchResults: {
domains: string[];
categories: string[];
subcodes: string[];
};
selectedSubcode: SelectedSubcode | null;
onSelectSubcode: (subcode: SelectedSubcode | null) => void;
}
export default function TaxonomyTree({
searchQuery,
searchResults,
selectedSubcode,
onSelectSubcode,
}: TaxonomyTreeProps) {
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
// Auto-expand nodes when search results change
useEffect(() => {
if (searchQuery) {
const domainsToExpand = new Set<string>();
const categoriesToExpand = new Set<string>();
// Expand domains that have matches
searchResults.domains.forEach((d) => domainsToExpand.add(d));
// Expand parent domains for matched categories
searchResults.categories.forEach((c) => {
domainsToExpand.add(c[0]);
categoriesToExpand.add(c);
});
// Expand parent domains and categories for matched subcodes
searchResults.subcodes.forEach((s) => {
const domainKey = s[0];
const categoryKey = s.slice(0, 2);
domainsToExpand.add(domainKey);
categoriesToExpand.add(categoryKey);
});
setExpandedDomains(domainsToExpand);
setExpandedCategories(categoriesToExpand);
}
}, [searchQuery, searchResults]);
const toggleDomain = useCallback((domainKey: string) => {
setExpandedDomains((prev) => {
const next = new Set(prev);
if (next.has(domainKey)) {
next.delete(domainKey);
} else {
next.add(domainKey);
}
return next;
});
}, []);
const toggleCategory = useCallback((categoryKey: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(categoryKey)) {
next.delete(categoryKey);
} else {
next.add(categoryKey);
}
return next;
});
}, []);
const handleSelectSubcode = (
subcodeKey: string,
domainKey: string,
domainName: string,
categoryKey: string,
categoryName: string,
subcode: SelectedSubcode['subcode']
) => {
onSelectSubcode({
code: subcodeKey,
domainKey,
domainName,
categoryKey,
categoryName,
subcode,
});
};
const isSubcodeSelected = (subcodeKey: string) => {
return selectedSubcode?.code === subcodeKey;
};
const isSearchMatch = (key: string) => {
if (!searchQuery) return false;
return (
searchResults.domains.includes(key) ||
searchResults.categories.includes(key) ||
searchResults.subcodes.includes(key)
);
};
// Filter to show only matches when searching
const shouldShowDomain = (domainKey: string) => {
if (!searchQuery) return true;
// Show domain if it matches or any of its children match
if (searchResults.domains.includes(domainKey)) return true;
if (searchResults.categories.some((c) => c.startsWith(domainKey))) return true;
if (searchResults.subcodes.some((s) => s.startsWith(domainKey))) return true;
return false;
};
const shouldShowCategory = (categoryKey: string) => {
if (!searchQuery) return true;
if (searchResults.categories.includes(categoryKey)) return true;
if (searchResults.subcodes.some((s) => s.startsWith(categoryKey))) return true;
return false;
};
const shouldShowSubcode = (subcodeKey: string) => {
if (!searchQuery) return true;
return searchResults.subcodes.includes(subcodeKey);
};
return (
<div className="flex flex-col gap-0.5">
{Object.entries(taxonomy.domains).map(([domainKey, domain]) => {
if (!shouldShowDomain(domainKey)) return null;
return (
<TreeNode
key={domainKey}
code={domainKey}
name={domain.name}
count={getDomainSubcodeCount(domainKey)}
isExpanded={expandedDomains.has(domainKey)}
level="domain"
domainKey={domainKey}
onToggle={() => toggleDomain(domainKey)}
searchMatch={isSearchMatch(domainKey)}
>
{Object.entries(domain.categories).map(([categoryKey, category]) => {
if (!shouldShowCategory(categoryKey)) return null;
return (
<TreeNode
key={categoryKey}
code={categoryKey}
name={category.name}
count={getSubcodeCount(categoryKey)}
isExpanded={expandedCategories.has(categoryKey)}
level="category"
domainKey={domainKey}
onToggle={() => toggleCategory(categoryKey)}
searchMatch={isSearchMatch(categoryKey)}
>
{Object.entries(category.subcodes).map(([subcodeKey, subcode]) => {
if (!shouldShowSubcode(subcodeKey)) return null;
return (
<TreeNode
key={subcodeKey}
code={subcodeKey}
name={subcode.name}
isLeaf
isSelected={isSubcodeSelected(subcodeKey)}
level="subcode"
domainKey={domainKey}
onClick={() =>
handleSelectSubcode(
subcodeKey,
domainKey,
domain.name,
categoryKey,
category.name,
subcode
)
}
searchMatch={isSearchMatch(subcodeKey)}
/>
);
})}
</TreeNode>
);
})}
</TreeNode>
);
})}
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { DOMAIN_TEXT_COLORS } from '@/lib/taxonomy/types';
interface TreeNodeProps {
code: string;
name: string;
count?: number;
isExpanded?: boolean;
isSelected?: boolean;
isLeaf?: boolean;
level: 'domain' | 'category' | 'subcode';
domainKey: string;
onToggle?: () => void;
onClick?: () => void;
searchMatch?: boolean;
children?: React.ReactNode;
}
export default function TreeNode({
code,
name,
count,
isExpanded = false,
isSelected = false,
isLeaf = false,
level,
domainKey,
onToggle,
onClick,
searchMatch = false,
children,
}: TreeNodeProps) {
const textColor = DOMAIN_TEXT_COLORS[domainKey] || 'text-gray-400';
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isLeaf) {
onClick?.();
} else {
onToggle?.();
}
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggle?.();
};
const getPadding = () => {
switch (level) {
case 'domain':
return 'pl-2';
case 'category':
return 'pl-6';
case 'subcode':
return 'pl-10';
default:
return 'pl-2';
}
};
return (
<div>
<div
onClick={handleClick}
className={`
flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer transition-colors
${getPadding()}
${isSelected ? 'bg-gray-700' : 'hover:bg-gray-800/50'}
${searchMatch ? 'ring-1 ring-yellow-500/50' : ''}
`}
>
{/* Expand/Collapse Icon */}
{!isLeaf ? (
<button
onClick={handleChevronClick}
className="w-4 h-4 flex items-center justify-center text-gray-500 hover:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
) : (
<span className="w-4 h-4 flex items-center justify-center text-gray-600">
<span className="w-1.5 h-1.5 rounded-full bg-current" />
</span>
)}
{/* Code */}
<span className={`font-mono text-sm ${textColor}`}>{code}</span>
{/* Name */}
<span className="text-sm text-gray-300 truncate flex-1">{name}</span>
{/* Count Badge */}
{count !== undefined && (
<span className="text-xs text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded">
{count}
</span>
)}
</div>
{/* Children (expanded content) */}
{isExpanded && children && <div>{children}</div>}
</div>
);
}