| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useState } from 'react'; |
| import { |
| Play, |
| RotateCcw, |
| Loader2, |
| ChevronDown, |
| Activity, |
| Heart, |
| User, |
| Stethoscope, |
| Pill, |
| Wind, |
| ClipboardList, |
| AlertCircle, |
| Shuffle, |
| BookOpen, |
| } from 'lucide-react'; |
| import { useDisease } from '../context/DiseaseContext'; |
| import { useDiseaseForm } from '../hooks/useDiseaseForm'; |
| import { SchemaErrorBoundary } from './ErrorBoundary'; |
| import SchemaFieldFactory from './SchemaFieldFactory'; |
| import FormSkeleton from './FormSkeleton'; |
| import { getCategoryIcon } from '../utils/featureCategorizer'; |
| import VariableScalesModal from './VariableScalesModal'; |
|
|
| |
| const CATEGORY_ICONS = { |
| Activity, Heart, User, Stethoscope, Pill, Wind, ClipboardList, |
| }; |
|
|
| function resolveIcon(iconName) { |
| return CATEGORY_ICONS[iconName] || ClipboardList; |
| } |
|
|
| |
| |
| |
| function CategoryCard({ category, fields, control, errors, defaultOpen }) { |
| const [isOpen, setIsOpen] = useState(defaultOpen); |
| const Icon = resolveIcon(getCategoryIcon(category)); |
|
|
| |
| const toggleFields = fields.filter((f) => f.component === 'toggle'); |
| const otherFields = fields.filter((f) => f.component !== 'toggle'); |
|
|
| return ( |
| <div className="card overflow-hidden"> |
| <button |
| type="button" |
| onClick={() => setIsOpen((prev) => !prev)} |
| className="card-header w-full flex items-center justify-between cursor-pointer hover:bg-gray-50 transition-colors" |
| > |
| <h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2"> |
| <Icon className="w-4 h-4 text-primary-500" /> |
| {category} |
| <span className="text-[10px] font-normal text-gray-400 ml-1"> |
| ({fields.length}) |
| </span> |
| </h3> |
| <ChevronDown |
| className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${ |
| isOpen ? 'rotate-180' : '' |
| }`} |
| /> |
| </button> |
| |
| {isOpen && ( |
| <div className="card-body border-t border-clinical-border"> |
| {/* Toggle fields β compact inline grid */} |
| {toggleFields.length > 0 && ( |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 mb-4"> |
| {toggleFields.map((field) => ( |
| <div key={field.name} className="flex items-center justify-between py-1"> |
| <SchemaFieldFactory |
| meta={field} |
| control={control} |
| errors={errors} |
| /> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| {/* Other fields β standard grid */} |
| {otherFields.length > 0 && ( |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| {otherFields.map((field) => ( |
| <SchemaFieldFactory |
| key={field.name} |
| meta={field} |
| control={control} |
| errors={errors} |
| /> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export default function DynamicClinicalForm({ |
| diseaseName, |
| onRunInference, |
| loading = false, |
| defaultOpenCategories = ['Vitals & Signs', 'Medical History'], |
| }) { |
| const [hasRun, setHasRun] = useState(false); |
| const [scalesModalOpen, setScalesModalOpen] = useState(false); |
|
|
| |
| const randomizeFields = () => { |
| const randomized = {}; |
| fields.forEach((f) => { |
| if (f.component === 'toggle' || f.component === 'segmented') { |
| randomized[f.name] = Math.random() > 0.5 ? 1 : 0; |
| } else if (f.component === 'select' && f.validation.enum?.length) { |
| const opts = f.validation.enum; |
| randomized[f.name] = opts[Math.floor(Math.random() * opts.length)]; |
| } else if (f.type === 'number' || f.type === 'integer') { |
| const min = f.validation.minimum ?? 0; |
| const max = f.validation.maximum ?? 100; |
| const step = f.validation.step ?? (f.type === 'number' ? 0.1 : 1); |
| const val = min + Math.random() * (max - min); |
| |
| const stepped = Math.round(val / step) * step; |
| randomized[f.name] = step < 1 |
| ? parseFloat(stepped.toFixed(10)) |
| : Math.round(stepped); |
| } else { |
| randomized[f.name] = f.default ?? ''; |
| } |
| }); |
| form.reset(randomized); |
| }; |
|
|
| |
| const { |
| form, |
| categorizedFields, |
| fields, |
| schemaLoading, |
| schemaError, |
| refetchSchema, |
| submitForm, |
| resetForm, |
| hasErrors, |
| } = useDiseaseForm(diseaseName, { |
| onSubmit: async (data) => { |
| setHasRun(true); |
| if (onRunInference) { |
| await onRunInference(data); |
| } |
| }, |
| }); |
|
|
| const { control, formState: { errors } } = form; |
|
|
| |
| if (schemaLoading) { |
| return ( |
| <FormSkeleton |
| fieldCount={fields.length || 12} |
| diseaseName={diseaseName} |
| /> |
| ); |
| } |
|
|
| |
| if (schemaError) { |
| return ( |
| <div className="card border-red-200"> |
| <div className="card-body text-center py-8"> |
| <AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" /> |
| <p className="text-sm font-medium text-red-700 mb-1"> |
| Failed to load form schema |
| </p> |
| <p className="text-xs text-red-500 mb-4">{schemaError}</p> |
| <button onClick={refetchSchema} className="btn-secondary text-xs"> |
| Retry |
| </button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| if (!fields || fields.length === 0) { |
| return ( |
| <div className="card"> |
| <div className="card-body text-center py-8 text-gray-400 text-sm"> |
| No form fields available for this disease. |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| return ( |
| <> |
| <form onSubmit={submitForm} className="space-y-6"> |
| {/* Form header with controls */} |
| <div className="flex items-center justify-between flex-wrap gap-3"> |
| <div> |
| <p className="text-xs text-gray-500"> |
| {fields.length} feature{fields.length !== 1 ? 's' : ''} Β·{' '} |
| {categorizedFields.size} categor{categorizedFields.size !== 1 ? 'ies' : 'y'} |
| </p> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button |
| type="button" |
| onClick={() => setScalesModalOpen(true)} |
| className="btn-secondary text-xs" |
| title="View clinical variable definitions and scales" |
| > |
| <BookOpen className="w-3.5 h-3.5" /> |
| Scales |
| </button> |
| <button |
| type="button" |
| onClick={randomizeFields} |
| className="btn-secondary text-xs" |
| title="Fill all fields with random valid data" |
| > |
| <Shuffle className="w-3.5 h-3.5" /> |
| Randomize |
| </button> |
| <button |
| type="button" |
| onClick={resetForm} |
| className="btn-secondary text-xs" |
| title="Reset to defaults" |
| > |
| <RotateCcw className="w-3.5 h-3.5" /> |
| Reset |
| </button> |
| <button |
| type="submit" |
| disabled={loading || hasErrors} |
| className="btn-primary text-sm" |
| > |
| {loading ? ( |
| <> |
| <Loader2 className="w-4 h-4 animate-spin" /> |
| Running Inference... |
| </> |
| ) : ( |
| <> |
| <Play className="w-4 h-4" /> |
| Run Inference |
| </> |
| )} |
| </button> |
| </div> |
| </div> |
| |
| {/* Validation error summary */} |
| {hasErrors && ( |
| <div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg"> |
| <AlertCircle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" /> |
| <p className="text-xs text-amber-700"> |
| Please fix the highlighted fields before running inference. |
| </p> |
| </div> |
| )} |
| |
| {/* Categorized accordion cards */} |
| <div className="space-y-4"> |
| {Array.from(categorizedFields.entries()).map(([category, categoryFields]) => ( |
| <CategoryCard |
| key={category} |
| category={category} |
| fields={categoryFields} |
| control={control} |
| errors={errors} |
| defaultOpen={defaultOpenCategories.includes(category)} |
| /> |
| ))} |
| </div> |
| </form> |
| |
| <VariableScalesModal |
| isOpen={scalesModalOpen} |
| onClose={() => setScalesModalOpen(false)} |
| fields={fields} |
| diseaseName={diseaseName} |
| categorizedFields={categorizedFields} |
| /> |
| </> |
| ); |
| } |
|
|