Files
triqura-ecd/components/cortex/blocks/dagnotitie-block.tsx
colinislit 05836c5c6a feat(cortex): implement activePatient fallback in various components
- Updated DagnotatieBlock to use activePatient for prefill if no patientId is provided.
- Enhanced PatientDashboardBlock to fallback to activePatient for patientId and name.
- Adjusted usePatientSelection to handle pending actions and re-route after patient selection.
- Refined chat components to manage nudge messages and integrate them into the chat flow.
- Improved UI elements for better patient selection experience.

This update enhances user experience by ensuring that the active patient context is utilized across multiple components, streamlining workflows and reducing manual input.
2026-01-03 22:18:30 +01:00

471 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* Dagnotatie Block
*
* Block voor het maken van een dagnotitie.
* E3.S2: Volledige implementatie met patient selectie, categorie, tekst en opslaan.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useCortexStore } from '@/stores/cortex-store';
import { BlockContainer } from './block-container';
import type { BlockPrefillData } from '@/stores/cortex-store';
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
import {
VERPLEEGKUNDIG_CATEGORIES,
CATEGORY_CONFIG,
type VerpleegkundigCategory,
} from '@/lib/types/report';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Loader2, Search, User, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { safeFetch, getErrorInfo, retryFetch } from '@/lib/cortex/error-handler';
import { evaluateNudge } from '@/lib/cortex/nudge';
import { formatPatientName as formatPatientNameFromDb } from '@/lib/fhir/patient-mapper';
interface DagnotitieBlockProps {
prefill?: BlockPrefillData;
}
interface Patient {
id: string;
name_family?: string;
name_given?: string[];
identifier_bsn?: string;
}
export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) {
const config = BLOCK_CONFIGS.dagnotitie;
const { closeBlock, addChatMessage, activePatient } = useCortexStore();
const { toast } = useToast();
// E3.S1: Determine initial patient from prefill OR activePatient
const initialPatientId = prefill?.patientId || activePatient?.id || '';
const initialPatientName = prefill?.patientName || (activePatient ? formatPatientNameFromDb(activePatient) : '');
const hasPrefillPatient = Boolean(prefill?.patientId);
// Form state - E3.S1: Use activePatient as fallback
const [patientId, setPatientId] = useState<string>(initialPatientId);
const [patientName, setPatientName] = useState<string>(initialPatientName);
const [category, setCategory] = useState<VerpleegkundigCategory>(
prefill?.category || 'observatie'
);
const [content, setContent] = useState<string>(prefill?.content || '');
const [includeInHandover, setIncludeInHandover] = useState<boolean>(false);
// Patient search state - E3.S1: Use activePatient as fallback
const [searchQuery, setSearchQuery] = useState<string>(initialPatientName);
const [patients, setPatients] = useState<Patient[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showPatientDropdown, setShowPatientDropdown] = useState(false);
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout>();
const dropdownRef = useRef<HTMLDivElement>(null);
// E3.S1: Prefill patient from prefill OR activePatient
useEffect(() => {
// Priority 1: Explicit prefill
if (prefill?.patientId && prefill?.patientName) {
setPatientId(prefill.patientId);
setPatientName(prefill.patientName);
setSearchQuery(prefill.patientName);
setSelectedPatient({
id: prefill.patientId,
name_family: prefill.patientName.split(' ').pop(),
name_given: prefill.patientName.split(' ').slice(0, -1),
});
}
// Priority 2: activePatient (only if no prefill patient)
else if (!hasPrefillPatient && activePatient) {
const name = formatPatientNameFromDb(activePatient);
setPatientId(activePatient.id);
setPatientName(name);
setSearchQuery(name);
setSelectedPatient({
id: activePatient.id,
name_family: activePatient.name_family || undefined,
name_given: activePatient.name_given || [],
identifier_bsn: activePatient.identifier_bsn || undefined,
});
}
}, [prefill, activePatient, hasPrefillPatient]);
// Patient search with debouncing
const searchPatients = useCallback(async (query: string) => {
if (query.length < 2) {
setPatients([]);
setShowPatientDropdown(false);
return;
}
setIsSearching(true);
try {
const response = await safeFetch(
`/api/fhir/Patient?q=${encodeURIComponent(query)}`,
undefined,
{ operation: 'Patiënt zoeken' }
);
const data = await response.json();
const mappedPatients: Patient[] =
data.entry?.map((e: { resource: any }) => {
const p = e.resource;
return {
id: p.id,
name_family: p.name?.[0]?.family,
name_given: p.name?.[0]?.given || [],
identifier_bsn: p.identifier?.find(
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
)?.value,
};
}) || [];
setPatients(mappedPatients);
setShowPatientDropdown(mappedPatients.length > 0);
} catch (error) {
console.error('Failed to search patients:', error);
const errorInfo = getErrorInfo(error, { operation: 'Patiënt zoeken' });
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
setPatients([]);
} finally {
setIsSearching(false);
}
}, [toast]);
// Debounced search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (searchQuery && !selectedPatient) {
searchTimeoutRef.current = setTimeout(() => {
searchPatients(searchQuery);
}, 300);
} else {
setPatients([]);
setShowPatientDropdown(false);
}
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, selectedPatient, searchPatients]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowPatientDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectPatient = (patient: Patient) => {
const fullName = `${patient.name_given?.join(' ') || ''} ${patient.name_family || ''}`.trim();
setSelectedPatient(patient);
setPatientId(patient.id);
setPatientName(fullName);
setSearchQuery(fullName);
setShowPatientDropdown(false);
};
const handleClearPatient = () => {
setSelectedPatient(null);
setPatientId('');
setPatientName('');
setSearchQuery('');
};
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSave = useCallback(async () => {
if (!patientId) {
toast({
variant: 'destructive',
title: 'Patiënt vereist',
description: 'Selecteer eerst een patiënt',
});
return;
}
if (!content.trim()) {
toast({
variant: 'destructive',
title: 'Content vereist',
description: 'Voer een notitie in',
});
return;
}
setIsSubmitting(true);
try {
const response = await retryFetch(
() =>
safeFetch(
'/api/reports',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
patient_id: patientId,
type: 'verpleegkundig',
content: content.trim(),
category,
include_in_handover: includeInHandover,
}),
},
{ operation: 'Dagnotitie opslaan' }
),
3,
1000
);
const data = await response.json();
toast({
title: 'Dagnotitie opgeslagen',
description: `Notitie voor ${patientName} is opgeslagen`,
});
// E4: Evaluate nudge after successful save
// Now adds nudges as chat messages instead of toast
const nudges = evaluateNudge({
intent: 'dagnotitie',
actionId: data.id || `dagnotitie-${Date.now()}`,
entities: {
patientName,
category,
},
content: content.trim(),
});
// Add nudge suggestions as chat messages
nudges.forEach((nudge) => {
addChatMessage({
type: 'nudge',
content: nudge.suggestion.message,
nudge: nudge,
});
console.log('[DagnotatieBlock] Nudge chat message:', nudge.suggestion.message);
});
// Close block after short delay
setTimeout(() => {
closeBlock();
}, 500);
} catch (error) {
console.error('Failed to save dagnotitie:', error);
const statusCode = (error as any)?.statusCode;
const errorInfo = getErrorInfo(error, {
operation: 'Dagnotitie opslaan',
statusCode,
});
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
} finally {
setIsSubmitting(false);
}
}, [patientId, content, category, includeInHandover, patientName, toast, closeBlock, addChatMessage]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await handleSave();
};
// Keyboard shortcut: Cmd/Ctrl+Enter to save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl+Enter: save dagnotitie
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave]);
const formatPatientName = (patient: Patient): string => {
const given = patient.name_given?.join(' ') || '';
const family = patient.name_family || '';
return `${given} ${family}`.trim() || 'Naamloos';
};
return (
<BlockContainer title={config.title} size={config.size}>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Patient Selectie */}
<div className="space-y-2">
<Label htmlFor="patient-search">Patiënt *</Label>
<div className="relative" ref={dropdownRef}>
{selectedPatient ? (
<div className="flex items-center gap-2 p-2 rounded-md border border-slate-200 bg-slate-50">
<User className="h-4 w-4 text-slate-500" />
<span className="flex-1 text-sm text-slate-900">{formatPatientName(selectedPatient)}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearPatient}
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-700"
>
×
</Button>
</div>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
id="patient-search"
type="text"
placeholder="Zoek patiënt (naam of BSN)..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowPatientDropdown(true);
}}
onFocus={() => {
if (patients.length > 0) {
setShowPatientDropdown(true);
}
}}
className="pl-9"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 animate-spin" />
)}
</div>
{showPatientDropdown && patients.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-md shadow-lg max-h-60 overflow-auto">
{patients.map((patient) => (
<button
key={patient.id}
type="button"
onClick={() => handleSelectPatient(patient)}
className="w-full px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors"
>
<div className="font-medium">{formatPatientName(patient)}</div>
{patient.identifier_bsn && (
<div className="text-xs text-slate-500">BSN: {patient.identifier_bsn}</div>
)}
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Categorie Selector */}
<div className="space-y-2">
<Label>Categorie *</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{VERPLEEGKUNDIG_CATEGORIES.map((cat) => {
const catConfig = CATEGORY_CONFIG[cat];
const isSelected = category === cat;
return (
<button
key={cat}
type="button"
onClick={() => setCategory(cat)}
className={cn(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isSelected
? 'bg-slate-900 text-white border-2 border-slate-700'
: 'bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100 hover:text-slate-900'
)}
>
{catConfig.label}
</button>
);
})}
</div>
</div>
{/* Content */}
<div className="space-y-2">
<Label htmlFor="content">Notitie *</Label>
<Textarea
id="content"
placeholder="Beschrijf wat er is gebeurd..."
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className="resize-none"
maxLength={500}
/>
<div className="text-xs text-slate-500 text-right">
{content.length}/500 karakters
</div>
</div>
{/* Include in Handover */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="include-handover"
checked={includeInHandover}
onChange={(e) => setIncludeInHandover(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 bg-white text-blue-600 focus:ring-blue-500"
/>
<Label htmlFor="include-handover" className="cursor-pointer text-sm text-slate-700">
Opnemen in overdracht
</Label>
</div>
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={closeBlock}
disabled={isSubmitting}
>
Annuleren
</Button>
<Button
type="submit"
disabled={isSubmitting || !patientId || !content.trim()}
title="Opslaan (⌘Enter)"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Opslaan...
</>
) : (
<>
Opslaan
<span className="ml-2 text-xs opacity-70 hidden sm:inline"></span>
</>
)}
</Button>
</div>
</form>
</BlockContainer>
);
}