Initial commit - WhyRating Engine (Google Reviews Scraper)
This commit is contained in:
127
web/components/taxonomy/CausalCodesSection.tsx
Normal file
127
web/components/taxonomy/CausalCodesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
web/components/taxonomy/MetadataSection.tsx
Normal file
111
web/components/taxonomy/MetadataSection.tsx
Normal 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"
|
||||
>
|
||||
"{marker}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
213
web/components/taxonomy/ProfilesSection.tsx
Normal file
213
web/components/taxonomy/ProfilesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
web/components/taxonomy/SubcodeDetail.tsx
Normal file
98
web/components/taxonomy/SubcodeDetail.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
61
web/components/taxonomy/TaxonomySearch.tsx
Normal file
61
web/components/taxonomy/TaxonomySearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
web/components/taxonomy/TaxonomyTree.tsx
Normal file
199
web/components/taxonomy/TaxonomyTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
web/components/taxonomy/TreeNode.tsx
Normal file
111
web/components/taxonomy/TreeNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user