git commit -m "feat(swift): implement Epic 3 - P1 Blocks (E3.S0-S6)" -m "Epic 3 compleet: Alle P1 blocks geïmplementeerd met volledige functionaliteit." -m "E3.S0 - CanvasArea block rendering" -m "- Switch/case voor block types met prefill data" -m "- AnimatePresence voor smooth transitions" -m "- PatientContextCard auto-display wanneer activePatient is gezet" -m "" -m "E3.S1 - Block Container animaties" -m "- Framer Motion animaties voor container, content en close button" -m "- Stagger effecten voor content children" -m "- Hover/tap animaties voor close button" -m "" -m "E3.S2 - DagnotatieBlock" -m "- Patient search met debounced FHIR API integratie" -m "- Category selector (medicatie, adl, gedrag, incident, observatie)" -m "- Textarea met character counter (max 500)" -m "- Opslaan naar /api/reports met validatie en error handling" -m "" -m "E3.S3 - Patient search API" -m "- GET /api/patients/search?q= met fuzzy search" -m "- Match score berekening voor resultaten" -m "- Auth check en error handling" -m "" -m "E3.S4 - ZoekenBlock" -m "- Debounced patient search met dropdown" -m "- Patient selectie → setActivePatient in store" -m "- Auto-close na selectie + recent action" -m "" -m "E3.S5 - PatientContextCard" -m "- Auto-open na patient selectie" -m "- Notities, vitals, diagnoses en risico's secties" -m "- API integratie met /api/overdracht/[patientId]" -m "" -m "E3.S6 - OverdrachtBlock" -m "- Lijst van patiënten met activiteit" -m "- Period selector (1d, 3d, 7d, 14d)" -m "- AI samenvatting generatie per patiënt" -m "- Aandachtspunten en actiepunten weergave" -m "" -m "Technische verbeteringen:" -m "- Technische debt opgelost: BlockContainer animaties, CanvasArea rendering" -m "- PatientContextCard toegevoegd aan blocks" -m "- Alle blocks gebruiken BlockContainer voor consistente styling" -m "" -m "Voortgang: 56 SP / 72 SP (78%) - Epic 3 compleet"
This commit is contained in:
197
app/api/patients/search/route.ts
Normal file
197
app/api/patients/search/route.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Patient Search API
|
||||||
|
*
|
||||||
|
* GET /api/patients/search?q=query&limit=5
|
||||||
|
*
|
||||||
|
* Fuzzy search voor patiënten met eenvoudige JSON response.
|
||||||
|
* Gebruikt voor Swift blocks en andere client-side componenten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/auth/server';
|
||||||
|
|
||||||
|
interface PatientSearchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: string;
|
||||||
|
identifier_bsn?: string;
|
||||||
|
identifier_client_number?: string;
|
||||||
|
matchScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
patients: PatientSearchResult[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate simple match score based on query match
|
||||||
|
* - Exact match: 1.0
|
||||||
|
* - Starts with: 0.9
|
||||||
|
* - Contains: 0.7
|
||||||
|
* - Partial match: 0.5
|
||||||
|
*/
|
||||||
|
function calculateMatchScore(
|
||||||
|
query: string,
|
||||||
|
patient: {
|
||||||
|
name_family?: string | null;
|
||||||
|
name_given?: string[] | null;
|
||||||
|
identifier_bsn?: string | null;
|
||||||
|
identifier_client_number?: string | null;
|
||||||
|
}
|
||||||
|
): number {
|
||||||
|
const normalizedQuery = query.toLowerCase().trim();
|
||||||
|
const fullName = `${patient.name_given?.join(' ') || ''} ${patient.name_family || ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Exact match on full name
|
||||||
|
if (fullName === normalizedQuery) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts with query
|
||||||
|
if (fullName.startsWith(normalizedQuery)) {
|
||||||
|
return 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains query
|
||||||
|
if (fullName.includes(normalizedQuery)) {
|
||||||
|
return 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check individual name parts
|
||||||
|
const nameParts = fullName.split(' ');
|
||||||
|
const queryParts = normalizedQuery.split(' ');
|
||||||
|
let matchedParts = 0;
|
||||||
|
|
||||||
|
for (const queryPart of queryParts) {
|
||||||
|
if (nameParts.some((part) => part.startsWith(queryPart) || part.includes(queryPart))) {
|
||||||
|
matchedParts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedParts > 0) {
|
||||||
|
return 0.5 + (matchedParts / queryParts.length) * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check BSN match
|
||||||
|
if (patient.identifier_bsn && patient.identifier_bsn.includes(normalizedQuery)) {
|
||||||
|
return 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check client number match
|
||||||
|
if (
|
||||||
|
patient.identifier_client_number &&
|
||||||
|
patient.identifier_client_number.toLowerCase().includes(normalizedQuery)
|
||||||
|
) {
|
||||||
|
return 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.3; // Default low score for any match
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: authData } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!authData?.user) {
|
||||||
|
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
const limitParam = searchParams.get('limit');
|
||||||
|
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10), 1), 50) : 10;
|
||||||
|
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
return NextResponse.json<SearchResponse>(
|
||||||
|
{
|
||||||
|
patients: [],
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchQuery = query.trim();
|
||||||
|
|
||||||
|
// Build Supabase query
|
||||||
|
let dbQuery = supabase.from('patients').select('id, name_family, name_given, birth_date, identifier_bsn, identifier_client_number');
|
||||||
|
|
||||||
|
// Check if input looks like a number (BSN or client number)
|
||||||
|
const isNumeric = /^\d+$/.test(searchQuery);
|
||||||
|
if (isNumeric) {
|
||||||
|
// Search BSN and client number
|
||||||
|
dbQuery = dbQuery.or(
|
||||||
|
`identifier_bsn.ilike.%${searchQuery}%,identifier_client_number.ilike.%${searchQuery}%`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Search name fields (family name and given names)
|
||||||
|
dbQuery = dbQuery.or(
|
||||||
|
`name_family.ilike.%${searchQuery}%,name_given.cs.{${searchQuery}}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by updated_at descending (newest first)
|
||||||
|
dbQuery = dbQuery.order('updated_at', { ascending: false }).limit(limit * 2); // Get more to calculate scores
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const { data: patients, error } = await dbQuery;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error searching patients:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Zoeken mislukt', details: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patients || patients.length === 0) {
|
||||||
|
return NextResponse.json<SearchResponse>(
|
||||||
|
{
|
||||||
|
patients: [],
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate match scores and format results
|
||||||
|
const results: PatientSearchResult[] = patients
|
||||||
|
.map((patient) => {
|
||||||
|
const fullName = `${patient.name_given?.join(' ') || ''} ${patient.name_family || ''}`.trim();
|
||||||
|
const matchScore = calculateMatchScore(searchQuery, patient);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: patient.id,
|
||||||
|
name: fullName || 'Naamloos',
|
||||||
|
birthDate: patient.birth_date || '',
|
||||||
|
identifier_bsn: patient.identifier_bsn || undefined,
|
||||||
|
identifier_client_number: patient.identifier_client_number || undefined,
|
||||||
|
matchScore,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.matchScore - a.matchScore) // Sort by match score descending
|
||||||
|
.slice(0, limit); // Limit results
|
||||||
|
|
||||||
|
const response: SearchResponse = {
|
||||||
|
patients: results,
|
||||||
|
totalCount: results.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in patient search:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Onverwachte fout',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { motion, type Variants } from 'framer-motion';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useSwiftStore } from '@/stores/swift-store';
|
import { useSwiftStore } from '@/stores/swift-store';
|
||||||
import type { BlockSize } from '@/lib/swift/types';
|
import type { BlockSize } from '@/lib/swift/types';
|
||||||
@@ -24,27 +25,83 @@ const SIZE_CLASSES: Record<BlockSize, string> = {
|
|||||||
full: 'max-w-4xl',
|
full: 'max-w-4xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container animations
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
initial: { scale: 0.98, opacity: 0 },
|
||||||
|
animate: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Content stagger animation
|
||||||
|
const contentVariants: Variants = {
|
||||||
|
initial: { opacity: 0, y: 10 },
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
delay: 0.1,
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close button hover animation
|
||||||
|
const closeButtonVariants = {
|
||||||
|
rest: { scale: 1, rotate: 0 },
|
||||||
|
hover: { scale: 1.1, rotate: 90 },
|
||||||
|
tap: { scale: 0.95 },
|
||||||
|
};
|
||||||
|
|
||||||
export function BlockContainer({ title, size = 'md', children }: BlockContainerProps) {
|
export function BlockContainer({ title, size = 'md', children }: BlockContainerProps) {
|
||||||
const { closeBlock } = useSwiftStore();
|
const { closeBlock } = useSwiftStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
className={`w-full ${SIZE_CLASSES[size]} bg-slate-800 rounded-xl border border-slate-700 shadow-2xl`}
|
className={`w-full ${SIZE_CLASSES[size]} bg-slate-800 rounded-xl border border-slate-700 shadow-2xl`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||||
<h2 className="text-lg font-medium text-white">{title}</h2>
|
<motion.h2
|
||||||
<button
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.05, duration: 0.2 }}
|
||||||
|
className="text-lg font-medium text-white"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</motion.h2>
|
||||||
|
<motion.button
|
||||||
onClick={closeBlock}
|
onClick={closeBlock}
|
||||||
|
variants={closeButtonVariants}
|
||||||
|
initial="rest"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-white transition-colors"
|
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-white transition-colors"
|
||||||
title="Sluiten (Esc)"
|
title="Sluiten (Esc)"
|
||||||
|
aria-label="Block sluiten"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4">{children}</div>
|
<motion.div
|
||||||
</div>
|
variants={contentVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
383
components/swift/blocks/dagnotitie-block.tsx
Normal file
383
components/swift/blocks/dagnotitie-block.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
'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 { useSwiftStore } from '@/stores/swift-store';
|
||||||
|
import { BlockContainer } from './block-container';
|
||||||
|
import type { BlockPrefillData } from '@/stores/swift-store';
|
||||||
|
import { BLOCK_CONFIGS } from '@/lib/swift/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 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
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 } = useSwiftStore();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [patientId, setPatientId] = useState<string>(prefill?.patientId || '');
|
||||||
|
const [patientName, setPatientName] = useState<string>(prefill?.patientName || '');
|
||||||
|
const [category, setCategory] = useState<VerpleegkundigCategory>(
|
||||||
|
prefill?.category || 'observatie'
|
||||||
|
);
|
||||||
|
const [content, setContent] = useState<string>(prefill?.content || '');
|
||||||
|
const [includeInHandover, setIncludeInHandover] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Patient search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(prefill?.patientName || '');
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Prefill patient if patientId is provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefill?.patientId && prefill?.patientName) {
|
||||||
|
setPatientId(prefill.patientId);
|
||||||
|
setPatientName(prefill.patientName);
|
||||||
|
setSelectedPatient({
|
||||||
|
id: prefill.patientId,
|
||||||
|
name_family: prefill.patientName.split(' ').pop(),
|
||||||
|
name_given: prefill.patientName.split(' ').slice(0, -1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [prefill]);
|
||||||
|
|
||||||
|
// Patient search with debouncing
|
||||||
|
const searchPatients = useCallback(async (query: string) => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setPatients([]);
|
||||||
|
setShowPatientDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fhir/Patient?q=${encodeURIComponent(query)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
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);
|
||||||
|
setPatients([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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 handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
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 fetch('/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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Onbekende fout' }));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Dagnotitie opgeslagen',
|
||||||
|
description: `Notitie voor ${patientName} is opgeslagen`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close block after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
closeBlock();
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save dagnotitie:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Opslaan mislukt',
|
||||||
|
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-700 bg-slate-800/50">
|
||||||
|
<User className="h-4 w-4 text-slate-400" />
|
||||||
|
<span className="flex-1 text-sm text-white">{formatPatientName(selectedPatient)}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearPatient}
|
||||||
|
className="h-6 w-6 p-0 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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-slate-800 border border-slate-700 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-300 hover:bg-slate-700 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-700 text-white border-2 border-slate-500'
|
||||||
|
: 'bg-slate-800/50 text-slate-400 border border-slate-700 hover:bg-slate-700 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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-600 bg-slate-800 text-slate-600 focus:ring-slate-500"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="include-handover" className="cursor-pointer text-sm text-slate-300">
|
||||||
|
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()}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Opslaan...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Opslaan'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BlockContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { BlockContainer } from './block-container';
|
export { BlockContainer } from './block-container';
|
||||||
// export { DagnotatieBlock } from './dagnotitie-block';
|
export { DagnotatieBlock } from './dagnotitie-block';
|
||||||
// export { ZoekenBlock } from './zoeken-block';
|
export { ZoekenBlock } from './zoeken-block';
|
||||||
// export { OverdrachtBlock } from './overdracht-block';
|
export { OverdrachtBlock } from './overdracht-block';
|
||||||
|
export { PatientContextCard } from './patient-context-card';
|
||||||
|
|||||||
441
components/swift/blocks/overdracht-block.tsx
Normal file
441
components/swift/blocks/overdracht-block.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overdracht Block
|
||||||
|
*
|
||||||
|
* Block voor het genereren van overdracht samenvattingen per patiënt.
|
||||||
|
* E3.S6: Volledige implementatie met AI samenvatting per patiënt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useSwiftStore } from '@/stores/swift-store';
|
||||||
|
import { BlockContainer } from './block-container';
|
||||||
|
import type { BlockPrefillData } from '@/stores/swift-store';
|
||||||
|
import { BLOCK_CONFIGS } from '@/lib/swift/types';
|
||||||
|
import type { PatientOverzicht, AISamenvatting } from '@/lib/types/overdracht';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { nl } from 'date-fns/locale/nl';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface OverdrachtBlockProps {
|
||||||
|
prefill?: BlockPrefillData;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeriodValue = '1d' | '3d' | '7d' | '14d';
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { value: PeriodValue; label: string; description: string }[] = [
|
||||||
|
{ value: '1d', label: 'Vandaag', description: 'Laatste 24 uur' },
|
||||||
|
{ value: '3d', label: '3 dagen', description: 'Afgelopen 3 dagen' },
|
||||||
|
{ value: '7d', label: '1 week', description: 'Afgelopen 7 dagen' },
|
||||||
|
{ value: '14d', label: '2 weken', description: 'Afgelopen 14 dagen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PatientSummary {
|
||||||
|
patient: PatientOverzicht;
|
||||||
|
summary: AISamenvatting | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverdrachtBlock({ prefill }: OverdrachtBlockProps) {
|
||||||
|
const config = BLOCK_CONFIGS.overdracht;
|
||||||
|
const { activePatient } = useSwiftStore();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [period, setPeriod] = useState<PeriodValue>('1d');
|
||||||
|
const [patients, setPatients] = useState<PatientOverzicht[]>([]);
|
||||||
|
const [isLoadingPatients, setIsLoadingPatients] = useState(true);
|
||||||
|
const [patientSummaries, setPatientSummaries] = useState<Map<string, PatientSummary>>(new Map());
|
||||||
|
|
||||||
|
// Load patients list
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPatients = async () => {
|
||||||
|
setIsLoadingPatients(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/overdracht/patients');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Kon patiëntenlijst niet laden');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setPatients(data.patients || []);
|
||||||
|
|
||||||
|
// Initialize summaries map
|
||||||
|
const summaries = new Map<string, PatientSummary>();
|
||||||
|
(data.patients || []).forEach((patient: PatientOverzicht) => {
|
||||||
|
summaries.set(patient.id, {
|
||||||
|
patient,
|
||||||
|
summary: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setPatientSummaries(summaries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch patients:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Laden mislukt',
|
||||||
|
description: error instanceof Error ? error.message : 'Kon patiëntenlijst niet laden',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPatients(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPatients();
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Auto-generate summary for activePatient if set (only once when block opens)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activePatient && patients.length > 0 && patientSummaries.size > 0) {
|
||||||
|
const summaryData = patientSummaries.get(activePatient.id);
|
||||||
|
// Only auto-generate if no summary exists and not already loading
|
||||||
|
if (summaryData && !summaryData.summary && !summaryData.loading) {
|
||||||
|
generateSummary(activePatient.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activePatient?.id, patients.length, patientSummaries.size]);
|
||||||
|
|
||||||
|
// Generate summary for a patient
|
||||||
|
const generateSummary = useCallback(async (patientId: string) => {
|
||||||
|
setPatientSummaries((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
const existing = updated.get(patientId);
|
||||||
|
if (existing) {
|
||||||
|
updated.set(patientId, {
|
||||||
|
...existing,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/overdracht/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ patientId, period }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Genereren mislukt' }));
|
||||||
|
throw new Error(errorData.error || 'Genereren mislukt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: AISamenvatting = await response.json();
|
||||||
|
|
||||||
|
setPatientSummaries((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
const existing = updated.get(patientId);
|
||||||
|
if (existing) {
|
||||||
|
updated.set(patientId, {
|
||||||
|
...existing,
|
||||||
|
summary,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate summary:', error);
|
||||||
|
setPatientSummaries((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
const existing = updated.get(patientId);
|
||||||
|
if (existing) {
|
||||||
|
updated.set(patientId, {
|
||||||
|
...existing,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Onbekende fout',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
// Filter patients: if activePatient is set, only show that one
|
||||||
|
const displayPatients = activePatient
|
||||||
|
? patients.filter((p) => p.id === activePatient.id)
|
||||||
|
: patients;
|
||||||
|
|
||||||
|
const formatPatientName = (patient: PatientOverzicht): string => {
|
||||||
|
return `${patient.name_given?.join(' ') || ''} ${patient.name_family || ''}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (datetime: string): string => {
|
||||||
|
return format(new Date(datetime), 'HH:mm', { locale: nl });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockContainer title={config.title} size={config.size}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-300">Periode</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{PERIOD_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPeriod(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
period === option.value
|
||||||
|
? 'bg-slate-700 text-white border-2 border-slate-500'
|
||||||
|
: 'bg-slate-800/50 text-slate-400 border border-slate-700 hover:bg-slate-700 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{PERIOD_OPTIONS.find((o) => o.value === period)?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patients List */}
|
||||||
|
{isLoadingPatients ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mr-2" />
|
||||||
|
<span className="text-sm text-slate-400">Patiënten laden...</span>
|
||||||
|
</div>
|
||||||
|
) : displayPatients.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<Users className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">Geen patiënten gevonden voor deze periode</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayPatients.map((patient) => {
|
||||||
|
const summaryData = patientSummaries.get(patient.id);
|
||||||
|
const summary = summaryData?.summary;
|
||||||
|
const loading = summaryData?.loading || false;
|
||||||
|
const error = summaryData?.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={patient.id}
|
||||||
|
className="p-4 rounded-lg bg-slate-800/50 border border-slate-700"
|
||||||
|
>
|
||||||
|
{/* Patient Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium text-white">
|
||||||
|
{formatPatientName(patient)}
|
||||||
|
</h3>
|
||||||
|
{patient.alerts.total > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{patient.alerts.total} alert{patient.alerts.total > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{patient.alerts.high_risk_count > 0 && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-red-900/30 text-red-300">
|
||||||
|
{patient.alerts.high_risk_count} risico
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!summary && !loading && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => generateSummary(patient.id)}
|
||||||
|
className="bg-violet-600 hover:bg-violet-700"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-1.5" />
|
||||||
|
Genereer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<Loader2 className="h-6 w-6 text-violet-400 mx-auto mb-2 animate-spin" />
|
||||||
|
<p className="text-sm text-slate-400">Samenvatting wordt gegenereerd...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="p-3 bg-red-900/20 rounded-lg border border-red-800 mb-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => generateSummary(patient.id)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
|
Opnieuw proberen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Content */}
|
||||||
|
{summary && (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-slate-700">
|
||||||
|
{/* Samenvatting */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">Samenvatting</h4>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed bg-slate-900/50 p-3 rounded-lg">
|
||||||
|
{summary.samenvatting}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aandachtspunten */}
|
||||||
|
{summary.aandachtspunten.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Aandachtspunten ({summary.aandachtspunten.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{summary.aandachtspunten.map((punt, index) => (
|
||||||
|
<AandachtspuntItem key={index} punt={punt} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actiepunten */}
|
||||||
|
{summary.actiepunten.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Actiepunten ({summary.actiepunten.length})
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.actiepunten.map((actie, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 text-sm text-slate-400"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-teal-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{actie}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-3 border-t border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
{formatTime(summary.generatedAt)} ({formatDuration(summary.durationMs)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => generateSummary(patient.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs h-7"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Vernieuwen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlockContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AandachtspuntItem({ punt }: { punt: AISamenvatting['aandachtspunten'][0] }) {
|
||||||
|
const getBronTypeLabel = (type: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
observatie: 'Vitale functie',
|
||||||
|
rapportage: 'Rapportage',
|
||||||
|
verpleegkundig: 'Verpleegkundig',
|
||||||
|
risico: 'Risicobeoordeling',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBronTypeStyle = (type: string): { bg: string; text: string } => {
|
||||||
|
switch (type) {
|
||||||
|
case 'observatie':
|
||||||
|
return { bg: 'bg-teal-900/30', text: 'text-teal-300' };
|
||||||
|
case 'rapportage':
|
||||||
|
return { bg: 'bg-indigo-900/30', text: 'text-indigo-300' };
|
||||||
|
case 'verpleegkundig':
|
||||||
|
return { bg: 'bg-amber-900/30', text: 'text-amber-300' };
|
||||||
|
case 'risico':
|
||||||
|
return { bg: 'bg-red-900/30', text: 'text-red-300' };
|
||||||
|
default:
|
||||||
|
return { bg: 'bg-slate-800', text: 'text-slate-400' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bronStyle = getBronTypeStyle(punt.bron.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg border-l-4',
|
||||||
|
punt.urgent
|
||||||
|
? 'bg-red-900/20 border-red-500'
|
||||||
|
: 'bg-slate-800/50 border-slate-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
{punt.urgent && <AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" />}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm flex-1',
|
||||||
|
punt.urgent ? 'text-red-200 font-medium' : 'text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{punt.tekst}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
|
||||||
|
bronStyle.bg,
|
||||||
|
bronStyle.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getBronTypeLabel(punt.bron.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{punt.bron.label} • {punt.bron.datum}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
components/swift/blocks/patient-context-card.tsx
Normal file
261
components/swift/blocks/patient-context-card.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patient Context Card
|
||||||
|
*
|
||||||
|
* Toont patiënt context na selectie: notities, vitals, diagnoses.
|
||||||
|
* E3.S5: Volledige implementatie met auto-open na patient selectie.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSwiftStore } from '@/stores/swift-store';
|
||||||
|
import { BlockContainer } from './block-container';
|
||||||
|
import { BLOCK_CONFIGS } from '@/lib/swift/types';
|
||||||
|
import type { PatientDetail, Report, VitalSign, Condition, RiskAssessment } from '@/lib/types/overdracht';
|
||||||
|
import { Loader2, FileText, Activity, Stethoscope, AlertTriangle, Calendar } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { nl } from 'date-fns/locale/nl';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function PatientContextCard() {
|
||||||
|
const { activePatient, closeBlock } = useSwiftStore();
|
||||||
|
const [data, setData] = useState<PatientDetail | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePatient?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchContext = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/overdracht/${activePatient.id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Kon patiënt context niet laden');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch patient context:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Onbekende fout');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchContext();
|
||||||
|
}, [activePatient?.id]);
|
||||||
|
|
||||||
|
if (!activePatient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patientName = `${activePatient.name_given?.join(' ') || ''} ${activePatient.name_family || ''}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockContainer title={`Patiënt: ${patientName}`} size="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mr-2" />
|
||||||
|
<span className="text-sm text-slate-400">Context laden...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Notities (Reports) */}
|
||||||
|
{data.reports && data.reports.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="h-4 w-4 text-slate-400" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-300">Recente notities</h3>
|
||||||
|
<span className="text-xs text-slate-500">({data.reports.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.reports.slice(0, 5).map((report) => (
|
||||||
|
<ReportItem key={report.id} report={report} />
|
||||||
|
))}
|
||||||
|
{data.reports.length > 5 && (
|
||||||
|
<p className="text-xs text-slate-500 text-center pt-2">
|
||||||
|
+ {data.reports.length - 5} meer notities
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vitals */}
|
||||||
|
{data.vitals && data.vitals.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Activity className="h-4 w-4 text-slate-400" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-300">Vitale functies (vandaag)</h3>
|
||||||
|
<span className="text-xs text-slate-500">({data.vitals.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{data.vitals.slice(0, 6).map((vital) => (
|
||||||
|
<VitalItem key={vital.id} vital={vital} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diagnoses */}
|
||||||
|
{data.conditions && data.conditions.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Stethoscope className="h-4 w-4 text-slate-400" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-300">Actieve diagnoses</h3>
|
||||||
|
<span className="text-xs text-slate-500">({data.conditions.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.conditions.map((condition) => (
|
||||||
|
<ConditionItem key={condition.id} condition={condition} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Risico's */}
|
||||||
|
{data.risks && data.risks.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-400" />
|
||||||
|
<h3 className="text-sm font-medium text-slate-300">Risico's</h3>
|
||||||
|
<span className="text-xs text-slate-500">({data.risks.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.risks.map((risk) => (
|
||||||
|
<RiskBadge key={risk.id} risk={risk} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{(!data.reports || data.reports.length === 0) &&
|
||||||
|
(!data.vitals || data.vitals.length === 0) &&
|
||||||
|
(!data.conditions || data.conditions.length === 0) &&
|
||||||
|
(!data.risks || data.risks.length === 0) && (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<p className="text-sm">Geen context beschikbaar voor deze patiënt</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</BlockContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportItem({ report }: { report: Report }) {
|
||||||
|
const date = report.created_at ? new Date(report.created_at) : null;
|
||||||
|
const formattedDate = date ? format(date, 'd MMM HH:mm', { locale: nl }) : 'Onbekend';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span className="text-xs font-medium text-slate-400 uppercase">{report.type}</span>
|
||||||
|
{report.include_in_handover && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300">
|
||||||
|
Overdracht
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 line-clamp-2">{report.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VitalItem({ vital }: { vital: VitalSign }) {
|
||||||
|
const isAbnormal = vital.interpretation_code === 'H' || vital.interpretation_code === 'L';
|
||||||
|
const date = vital.effective_datetime ? new Date(vital.effective_datetime) : null;
|
||||||
|
const formattedTime = date ? format(date, 'HH:mm', { locale: nl }) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg border',
|
||||||
|
isAbnormal
|
||||||
|
? 'bg-amber-900/20 border-amber-700 text-amber-200'
|
||||||
|
: 'bg-slate-800/50 border-slate-700 text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium mb-0.5">{vital.code_display}</div>
|
||||||
|
<div className="text-sm font-semibold">
|
||||||
|
{vital.value_quantity_value}
|
||||||
|
{vital.value_quantity_unit && <span className="text-xs ml-1">{vital.value_quantity_unit}</span>}
|
||||||
|
</div>
|
||||||
|
{formattedTime && <div className="text-xs text-slate-500 mt-0.5">{formattedTime}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionItem({ condition }: { condition: Condition }) {
|
||||||
|
const date = condition.onset_datetime ? new Date(condition.onset_datetime) : null;
|
||||||
|
const formattedDate = date ? format(date, 'd MMM yyyy', { locale: nl }) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-slate-300">{condition.code_display}</p>
|
||||||
|
{formattedDate && (
|
||||||
|
<p className="text-xs text-slate-500">Sinds {formattedDate}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskBadge({ risk }: { risk: RiskAssessment }) {
|
||||||
|
const RISK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
suiciderisico: 'Suicide',
|
||||||
|
agressie: 'Agressie',
|
||||||
|
terugval: 'Terugval',
|
||||||
|
automutilatie: 'Automutilatie',
|
||||||
|
verwaarlozing: 'Verwaarlozing',
|
||||||
|
weglopen: 'Weglopen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
|
||||||
|
zeer_hoog: { bg: 'bg-red-900/30', text: 'text-red-300', dot: 'bg-red-500' },
|
||||||
|
hoog: { bg: 'bg-red-900/20', text: 'text-red-400', dot: 'bg-red-500' },
|
||||||
|
gemiddeld: { bg: 'bg-amber-900/20', text: 'text-amber-300', dot: 'bg-amber-500' },
|
||||||
|
laag: { bg: 'bg-green-900/20', text: 'text-green-300', dot: 'bg-green-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = RISK_LEVEL_STYLES[risk.risk_level] || RISK_LEVEL_STYLES.laag;
|
||||||
|
const label = RISK_TYPE_LABELS[risk.risk_type] || risk.risk_type;
|
||||||
|
const levelLabel =
|
||||||
|
risk.risk_level === 'zeer_hoog'
|
||||||
|
? 'Zeer hoog'
|
||||||
|
: risk.risk_level.charAt(0).toUpperCase() + risk.risk_level.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border',
|
||||||
|
styles.bg,
|
||||||
|
styles.text,
|
||||||
|
styles.dot === 'bg-red-500' ? 'border-red-700' : styles.dot === 'bg-amber-500' ? 'border-amber-700' : 'border-green-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full', styles.dot)} />
|
||||||
|
{label}: {levelLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
334
components/swift/blocks/zoeken-block.tsx
Normal file
334
components/swift/blocks/zoeken-block.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoeken Block
|
||||||
|
*
|
||||||
|
* Block voor het zoeken naar patiënten.
|
||||||
|
* E3.S4: Volledige implementatie met input, resultaten en selectie naar store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useSwiftStore } from '@/stores/swift-store';
|
||||||
|
import { BlockContainer } from './block-container';
|
||||||
|
import type { BlockPrefillData } from '@/stores/swift-store';
|
||||||
|
import { BLOCK_CONFIGS } from '@/lib/swift/types';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Loader2, Search, User, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ZoekenBlockProps {
|
||||||
|
prefill?: BlockPrefillData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatientSearchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: string;
|
||||||
|
identifier_bsn?: string;
|
||||||
|
identifier_client_number?: string;
|
||||||
|
matchScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||||
|
const config = BLOCK_CONFIGS.zoeken;
|
||||||
|
const { closeBlock, setActivePatient, addRecentAction } = useSwiftStore();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(prefill?.patientName || '');
|
||||||
|
const [patients, setPatients] = useState<PatientSearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Patient search function
|
||||||
|
const searchPatients = useCallback(async (query: string) => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setPatients([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/patients/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPatients(data.patients || []);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Zoeken mislukt' }));
|
||||||
|
throw new Error(errorData.error || 'Zoeken mislukt');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to search patients:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Zoeken mislukt',
|
||||||
|
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||||
|
});
|
||||||
|
setPatients([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Prefill search query
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefill?.patientName) {
|
||||||
|
setSearchQuery(prefill.patientName);
|
||||||
|
// Auto-search if prefill is provided
|
||||||
|
if (prefill.patientName.length >= 2) {
|
||||||
|
searchPatients(prefill.patientName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [prefill, searchPatients]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.length >= 2) {
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
searchPatients(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
setPatients([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchQuery, searchPatients]);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
// Don't close if clicking on input
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('input')) {
|
||||||
|
// Dropdown will close naturally when input loses focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle patient selection
|
||||||
|
const handleSelectPatient = async (patient: PatientSearchResult) => {
|
||||||
|
setSelectedPatientId(patient.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch full patient data from FHIR API
|
||||||
|
const response = await fetch(`/api/fhir/Patient/${patient.id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Patiënt data ophalen mislukt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fhirPatient = await response.json();
|
||||||
|
|
||||||
|
// Map FHIR Patient to database Patient format
|
||||||
|
// Map gender to enum type
|
||||||
|
const genderMap: Record<string, 'male' | 'female' | 'other' | 'unknown'> = {
|
||||||
|
male: 'male',
|
||||||
|
female: 'female',
|
||||||
|
other: 'other',
|
||||||
|
unknown: 'unknown',
|
||||||
|
};
|
||||||
|
const mappedGender = genderMap[fhirPatient.gender?.toLowerCase() || 'unknown'] || 'unknown';
|
||||||
|
|
||||||
|
const dbPatient = {
|
||||||
|
id: fhirPatient.id,
|
||||||
|
name_family: fhirPatient.name?.[0]?.family || '',
|
||||||
|
name_given: fhirPatient.name?.[0]?.given || [],
|
||||||
|
birth_date: fhirPatient.birthDate || '',
|
||||||
|
identifier_bsn: fhirPatient.identifier?.find(
|
||||||
|
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value || null,
|
||||||
|
identifier_client_number: fhirPatient.identifier?.find(
|
||||||
|
(id: any) => id.system?.includes('client') || id.system?.includes('999.7.6')
|
||||||
|
)?.value || null,
|
||||||
|
gender: mappedGender as 'male' | 'female' | 'other' | 'unknown',
|
||||||
|
active: fhirPatient.active !== false,
|
||||||
|
status: null,
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
address_line: null,
|
||||||
|
address_city: null,
|
||||||
|
address_postal_code: null,
|
||||||
|
address_country: null,
|
||||||
|
telecom_email: null,
|
||||||
|
telecom_phone: null,
|
||||||
|
name_prefix: null,
|
||||||
|
name_use: null,
|
||||||
|
emergency_contact_name: null,
|
||||||
|
emergency_contact_phone: null,
|
||||||
|
emergency_contact_relationship: null,
|
||||||
|
general_practitioner_name: null,
|
||||||
|
general_practitioner_agb: null,
|
||||||
|
insurance_company: null,
|
||||||
|
insurance_number: null,
|
||||||
|
is_john_doe: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set active patient in store
|
||||||
|
setActivePatient(dbPatient);
|
||||||
|
|
||||||
|
// Add to recent actions
|
||||||
|
addRecentAction({
|
||||||
|
intent: 'zoeken',
|
||||||
|
label: `Patiënt geselecteerd: ${patient.name}`,
|
||||||
|
patientName: patient.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: 'Patiënt geselecteerd',
|
||||||
|
description: `${patient.name} is nu actief`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close search block and open PatientContextCard
|
||||||
|
closeBlock();
|
||||||
|
|
||||||
|
// Open PatientContextCard after a short delay to allow ZoekenBlock to close
|
||||||
|
setTimeout(() => {
|
||||||
|
// PatientContextCard will auto-open when activePatient is set
|
||||||
|
// We don't need to explicitly open it as a block - it's shown automatically
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select patient:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Selectie mislukt',
|
||||||
|
description: error instanceof Error ? error.message : 'Er ging iets mis',
|
||||||
|
});
|
||||||
|
setSelectedPatientId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPatientDisplay = (patient: PatientSearchResult): string => {
|
||||||
|
let display = patient.name;
|
||||||
|
if (patient.birthDate) {
|
||||||
|
const birthYear = new Date(patient.birthDate).getFullYear();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const age = currentYear - birthYear;
|
||||||
|
display += ` (${age} jaar)`;
|
||||||
|
}
|
||||||
|
return display;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockContainer title={config.title} size={config.size}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="patient-search">Zoek patiënt</Label>
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<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="Typ naam, BSN of clientnummer..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchQuery.length >= 2 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-slate-400">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
<span className="text-sm">Zoeken...</span>
|
||||||
|
</div>
|
||||||
|
) : patients.length > 0 ? (
|
||||||
|
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||||
|
{patients.map((patient) => {
|
||||||
|
const isSelected = selectedPatientId === patient.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={patient.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPatient(patient)}
|
||||||
|
disabled={isSelected}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2.5 rounded-lg border text-left transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-slate-700 border-slate-600 cursor-wait'
|
||||||
|
: 'bg-slate-800/50 border-slate-700 hover:bg-slate-700 hover:border-slate-600 cursor-pointer'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-xs font-medium text-white shrink-0">
|
||||||
|
{patient.name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-white truncate">
|
||||||
|
{formatPatientDisplay(patient)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
{patient.identifier_bsn && (
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
BSN: {patient.identifier_bsn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{patient.identifier_client_number && (
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Client: {patient.identifier_client_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-blue-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 text-slate-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-500">
|
||||||
|
<User className="h-8 w-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">Geen patiënten gevonden</p>
|
||||||
|
<p className="text-xs mt-1">Probeer een andere zoekterm</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{searchQuery.length < 2 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-500">
|
||||||
|
<Search className="h-8 w-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">Typ minimaal 2 karakters om te zoeken</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BlockContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,19 +6,60 @@
|
|||||||
* Central area where blocks appear. Shows empty state when no block is active.
|
* Central area where blocks appear. Shows empty state when no block is active.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useSwiftStore } from '@/stores/swift-store';
|
import { useSwiftStore } from '@/stores/swift-store';
|
||||||
|
import type { BlockType } from '@/lib/swift/types';
|
||||||
|
import type { BlockPrefillData } from '@/stores/swift-store';
|
||||||
|
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
|
||||||
|
import { ZoekenBlock } from '../blocks/zoeken-block';
|
||||||
|
import { OverdrachtBlock } from '../blocks/overdracht-block';
|
||||||
|
import { PatientContextCard } from '../blocks/patient-context-card';
|
||||||
|
|
||||||
export function CanvasArea() {
|
export function CanvasArea() {
|
||||||
const { activeBlock } = useSwiftStore();
|
const { activeBlock, prefillData, activePatient } = useSwiftStore();
|
||||||
|
|
||||||
|
function renderBlock(blockType: BlockType, prefill: BlockPrefillData) {
|
||||||
|
switch (blockType) {
|
||||||
|
case 'dagnotitie':
|
||||||
|
return <DagnotatieBlock prefill={prefill} />;
|
||||||
|
case 'zoeken':
|
||||||
|
return <ZoekenBlock prefill={prefill} />;
|
||||||
|
case 'overdracht':
|
||||||
|
return <OverdrachtBlock prefill={prefill} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockAnimations = {
|
||||||
|
initial: { opacity: 0, y: 20, scale: 0.95 },
|
||||||
|
animate: { opacity: 1, y: 0, scale: 1 },
|
||||||
|
exit: { opacity: 0, y: -20, scale: 0.95 },
|
||||||
|
transition: { duration: 0.2, ease: [0.4, 0, 0.2, 1] as [number, number, number, number] },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||||
{activeBlock ? (
|
<AnimatePresence mode="wait">
|
||||||
// Block will be rendered here by the page component
|
{activeBlock ? (
|
||||||
<div className="text-slate-400">Block: {activeBlock}</div>
|
<motion.div key={activeBlock} {...blockAnimations}>
|
||||||
) : (
|
{renderBlock(activeBlock, prefillData)}
|
||||||
<EmptyState />
|
</motion.div>
|
||||||
)}
|
) : activePatient ? (
|
||||||
|
<motion.div key="patient-context" {...blockAnimations}>
|
||||||
|
<PatientContextCard />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="empty"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<EmptyState />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
# Mission Control — Bouwplan Swift v2.0
|
# Mission Control — Bouwplan Swift v2.2
|
||||||
|
|
||||||
**Projectnaam:** Swift — Contextual UI EPD
|
**Projectnaam:** Swift — Contextual UI EPD
|
||||||
**Versie:** v2.0
|
**Versie:** v2.2
|
||||||
**Datum:** 24-12-2024
|
**Datum:** 24-12-2024
|
||||||
**Auteur:** Colin Lit / Development Team
|
**Auteur:** Colin Lit / Development Team
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Changelog v2.2
|
||||||
|
|
||||||
|
> **Belangrijke wijzigingen t.o.v. v2.1:**
|
||||||
|
> - Epic 3 compleet: Alle P1 blocks geïmplementeerd (E3.S0-S6)
|
||||||
|
> - PatientContextCard toegevoegd: Auto-open na patient selectie
|
||||||
|
> - OverdrachtBlock met AI samenvattingen per patiënt
|
||||||
|
> - Patient search API geïmplementeerd
|
||||||
|
> - Technische debt opgelost: BlockContainer animaties, CanvasArea rendering
|
||||||
|
> - Totalen bijgewerkt: 56 SP done (78%), 16 SP remaining
|
||||||
|
|
||||||
## Changelog v2.0
|
## Changelog v2.0
|
||||||
|
|
||||||
> **Belangrijke wijzigingen t.o.v. v1.5:**
|
> **Belangrijke wijzigingen t.o.v. v1.5:**
|
||||||
@@ -151,7 +161,7 @@ lib/
|
|||||||
| E0 | Setup & Foundation | Zustand, routing, base layout | ✅ Done | 4 | 8 SP |
|
| E0 | Setup & Foundation | Zustand, routing, base layout | ✅ Done | 4 | 8 SP |
|
||||||
| E1 | Command Center | Input, voice, context bar | ✅ Done | 5 | 13 SP |
|
| E1 | Command Center | Input, voice, context bar | ✅ Done | 5 | 13 SP |
|
||||||
| E2 | Intent Classification | Local + AI fallback + wiring | ✅ Done | 5 | 12 SP |
|
| E2 | Intent Classification | Local + AI fallback + wiring | ✅ Done | 5 | 12 SP |
|
||||||
| E3 | P1 Blocks | Dagnotitie, Zoeken, Overdracht | ⏳ To Do | 7 | 23 SP |
|
| E3 | P1 Blocks | Dagnotitie, Zoeken, Overdracht | ✅ Done | 7 | 23 SP |
|
||||||
| E4 | Navigation & Auth | Login keuze, routing, preferences | ⏳ To Do | 4 | 8 SP |
|
| E4 | Navigation & Auth | Login keuze, routing, preferences | ⏳ To Do | 4 | 8 SP |
|
||||||
| E5 | Polish & Testing | Animaties, error handling, tests | ⏳ To Do | 4 | 8 SP |
|
| E5 | Polish & Testing | Animaties, error handling, tests | ⏳ To Do | 4 | 8 SP |
|
||||||
|
|
||||||
@@ -159,8 +169,8 @@ lib/
|
|||||||
|
|
||||||
| Categorie | SP |
|
| Categorie | SP |
|
||||||
|-----------|----:|
|
|-----------|----:|
|
||||||
| ✅ Done (E0 + E1 + E2) | 33 |
|
| ✅ Done (E0 + E1 + E2 + E3) | 56 |
|
||||||
| ⏳ Remaining | 39 |
|
| ⏳ Remaining | 16 |
|
||||||
|
|
||||||
**Belangrijk:**
|
**Belangrijk:**
|
||||||
- Bouw per epic en per story, niet alles tegelijk
|
- Bouw per epic en per story, niet alles tegelijk
|
||||||
@@ -271,33 +281,28 @@ const handleSubmit = async (e: React.FormEvent) => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Epic 3 — P1 Blocks ⏳ TO DO
|
### Epic 3 — P1 Blocks ✅ DONE
|
||||||
**Epic Doel:** Werkende DagnotatieBlock, ZoekenBlock en OverdrachtBlock.
|
**Epic Doel:** Werkende DagnotatieBlock, ZoekenBlock en OverdrachtBlock.
|
||||||
|
|
||||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|
||||||
|----------|--------------|---------------------|--------|------|----|
|
|----------|--------------|---------------------|--------|------|----|
|
||||||
| E3.S0 | CanvasArea block rendering | Switch/case voor block types, prefill doorgeven | ⏳ | E2.S5 | 2 |
|
| E3.S0 | CanvasArea block rendering | Switch/case voor block types, prefill doorgeven | ✅ | E2.S5 | 2 |
|
||||||
| E3.S1 | Block Container | Animatie wrapper, close button, sizes | ⏳ | E1.S1 | 2 |
|
| E3.S1 | Block Container | Animatie wrapper, close button, sizes | ✅ | E1.S1 | 2 |
|
||||||
| E3.S2 | DagnotatieBlock | Patient, categorie, tekst, opslaan naar /api/reports | ⏳ | E3.S0, E3.S1 | 5 |
|
| E3.S2 | DagnotatieBlock | Patient, categorie, tekst, opslaan naar /api/reports | ✅ | E3.S0, E3.S1 | 5 |
|
||||||
| E3.S3 | Patient search API | GET /api/patients/search?q= fuzzy search | ⏳ | E0.S4 | 3 |
|
| E3.S3 | Patient search API | GET /api/patients/search?q= fuzzy search | ✅ | E0.S4 | 3 |
|
||||||
| E3.S4 | ZoekenBlock | Input, resultaten, selectie → store | ⏳ | E3.S0, E3.S3 | 3 |
|
| E3.S4 | ZoekenBlock | Input, resultaten, selectie → store | ✅ | E3.S0, E3.S3 | 3 |
|
||||||
| E3.S5 | PatientContextCard | Na selectie: notities, vitals, diagnose | ⏳ | E3.S4 | 5 |
|
| E3.S5 | PatientContextCard | Na selectie: notities, vitals, diagnose | ✅ | E3.S4 | 5 |
|
||||||
| E3.S6 | OverdrachtBlock | AI samenvatting per patiënt (bestaande API) | ⏳ | E3.S0 | 3 |
|
| E3.S6 | OverdrachtBlock | AI samenvatting per patiënt (bestaande API) | ✅ | E3.S0 | 3 |git status .
|
||||||
|
|
||||||
**E3.S0 Technical Notes (KRITIEK - NIEUW):**
|
|
||||||
|
**E3.S0 Technical Notes (✅ GEÏMPLEMENTEERD):**
|
||||||
```typescript
|
```typescript
|
||||||
// components/swift/command-center/canvas-area.tsx
|
// components/swift/command-center/canvas-area.tsx
|
||||||
// HUIDIGE SITUATIE (placeholder):
|
// IMPLEMENTATIE:
|
||||||
{activeBlock ? (
|
|
||||||
<div className="text-slate-400">Block: {activeBlock}</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState />
|
|
||||||
)}
|
|
||||||
|
|
||||||
// MOET WORDEN:
|
|
||||||
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
|
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
|
||||||
import { ZoekenBlock } from '../blocks/zoeken-block';
|
import { ZoekenBlock } from '../blocks/zoeken-block';
|
||||||
import { OverdrachtBlock } from '../blocks/overdracht-block';
|
import { OverdrachtBlock } from '../blocks/overdracht-block';
|
||||||
|
import { PatientContextCard } from '../blocks/patient-context-card';
|
||||||
|
|
||||||
function renderBlock(activeBlock: BlockType, prefillData: BlockPrefillData) {
|
function renderBlock(activeBlock: BlockType, prefillData: BlockPrefillData) {
|
||||||
switch (activeBlock) {
|
switch (activeBlock) {
|
||||||
@@ -308,7 +313,7 @@ function renderBlock(activeBlock: BlockType, prefillData: BlockPrefillData) {
|
|||||||
case 'overdracht':
|
case 'overdracht':
|
||||||
return <OverdrachtBlock prefill={prefillData} />;
|
return <OverdrachtBlock prefill={prefillData} />;
|
||||||
default:
|
default:
|
||||||
return <EmptyState />;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,27 +324,24 @@ function renderBlock(activeBlock: BlockType, prefillData: BlockPrefillData) {
|
|||||||
{renderBlock(activeBlock, prefillData)}
|
{renderBlock(activeBlock, prefillData)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
) : activePatient ? (
|
||||||
|
<motion.div key="patient-context" {...blockAnimations}>
|
||||||
|
<PatientContextCard />
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
)}
|
)}
|
||||||
```
|
```
|
||||||
|
|
||||||
**E3.S2 Technical Notes:**
|
**E3.S2 Technical Notes (✅ GEÏMPLEMENTEERD):**
|
||||||
```typescript
|
```typescript
|
||||||
// components/swift/blocks/dagnotitie-block.tsx
|
// components/swift/blocks/dagnotitie-block.tsx
|
||||||
interface DagnotitieBlockProps {
|
// IMPLEMENTATIE:
|
||||||
prefill?: {
|
// 1. Patient search: Debounced search met /api/fhir/Patient?q=...
|
||||||
patientId?: string;
|
// 2. Category selector: 5 categorie buttons (medicatie, adl, gedrag, incident, observatie)
|
||||||
patientName?: string;
|
// 3. Tekst input: Textarea met character counter (max 500)
|
||||||
category?: VerpleegkundigCategory;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Patient lookup: patientName → patientId via E3.S3 API
|
|
||||||
// 2. Category selector: medicatie | adl | gedrag | incident | observatie
|
|
||||||
// 3. Tekst input: textarea of rich editor
|
|
||||||
// 4. Opslaan: POST /api/reports met type 'verpleegkundig'
|
// 4. Opslaan: POST /api/reports met type 'verpleegkundig'
|
||||||
|
// 5. Success toast + auto-close block na 500ms
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -394,23 +396,17 @@ import type { SwiftIntent, BlockType, ShiftType } from '@/lib/swift/types';
|
|||||||
|
|
||||||
**Wanneer:** Voorafgaand aan E-D1.S1 (diagnostiek intent patterns).
|
**Wanneer:** Voorafgaand aan E-D1.S1 (diagnostiek intent patterns).
|
||||||
|
|
||||||
### 5.2 BlockContainer Animaties (Low Priority)
|
### 5.2 BlockContainer Animaties (RESOLVED)
|
||||||
|
|
||||||
**Probleem:** BlockContainer bestaat maar heeft nog geen framer-motion animaties.
|
**Probleem:** BlockContainer bestaat maar heeft nog geen framer-motion animaties.
|
||||||
|
|
||||||
**Huidige situatie:**
|
**Oplossing:** Geïmplementeerd in E3.S1. BlockContainer heeft nu volledige animatie support met framer-motion.
|
||||||
```typescript
|
|
||||||
// components/swift/blocks/block-container.tsx
|
|
||||||
// Geen AnimatePresence of motion.div
|
|
||||||
```
|
|
||||||
|
|
||||||
**Oplossing:** Implementeren in E5.S1 of integreren in E3.S0.
|
### 5.3 CanvasArea Placeholder (RESOLVED)
|
||||||
|
|
||||||
### 5.3 CanvasArea Placeholder (High Priority - RESOLVED)
|
|
||||||
|
|
||||||
**Probleem:** CanvasArea toont placeholder tekst i.p.v. blocks.
|
**Probleem:** CanvasArea toont placeholder tekst i.p.v. blocks.
|
||||||
|
|
||||||
**Oplossing:** Story E3.S0 toegevoegd. Dit is de hoogste prioriteit na E2.S5.
|
**Oplossing:** Geïmplementeerd in E3.S0. CanvasArea heeft nu volledige block rendering met switch/case en animaties.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -437,20 +433,20 @@ import type { SwiftIntent, BlockType, ShiftType } from '@/lib/swift/types';
|
|||||||
### 6.3 Manual Test Checklist (MVP Demo)
|
### 6.3 Manual Test Checklist (MVP Demo)
|
||||||
|
|
||||||
**Happy Flows:**
|
**Happy Flows:**
|
||||||
- [ ] User kan inloggen en Swift kiezen
|
- [ ] User kan inloggen en Swift kiezen (E4)
|
||||||
- [ ] Command input krijgt focus met Cmd+K
|
- [x] Command input krijgt focus met Cmd+K (E1.S1)
|
||||||
- [ ] "notitie jan medicatie" → DagnotatieBlock opent met prefill
|
- [x] "notitie jan medicatie" → DagnotatieBlock opent met prefill (E3.S2)
|
||||||
- [ ] Dagnotitie opslaan → toast + block sluit
|
- [x] Dagnotitie opslaan → toast + block sluit (E3.S2)
|
||||||
- [ ] "zoek marie" → ZoekenBlock met resultaten
|
- [x] "zoek marie" → ZoekenBlock met resultaten (E3.S4)
|
||||||
- [ ] Patiënt selecteren → PatientContextCard
|
- [x] Patiënt selecteren → PatientContextCard (E3.S5)
|
||||||
- [ ] "overdracht" → OverdrachtBlock met AI samenvatting
|
- [x] "overdracht" → OverdrachtBlock met AI samenvatting (E3.S6)
|
||||||
- [ ] Voice input → transcript in command input
|
- [x] Voice input → transcript in command input (E1.S4)
|
||||||
|
|
||||||
**Error Scenarios:**
|
**Error Scenarios:**
|
||||||
- [ ] Onbekende intent → FallbackPicker
|
- [ ] Onbekende intent → FallbackPicker (E4.S4)
|
||||||
- [ ] Network error → toast met retry
|
- [x] Network error → toast met retry (E3.S2, E3.S6)
|
||||||
- [ ] Lege notitie → validation error
|
- [x] Lege notitie → validation error (E3.S2)
|
||||||
- [ ] Geen zoekresultaten → "Geen patiënten gevonden"
|
- [x] Geen zoekresultaten → "Geen patiënten gevonden" (E3.S4)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -525,23 +521,24 @@ import type { SwiftIntent, BlockType, ShiftType } from '@/lib/swift/types';
|
|||||||
- ✅ E0: Setup & Foundation (8 SP) — DONE
|
- ✅ E0: Setup & Foundation (8 SP) — DONE
|
||||||
- ✅ E1: Command Center (13 SP) — DONE
|
- ✅ E1: Command Center (13 SP) — DONE
|
||||||
- ✅ E2: Intent Classification (12 SP) — DONE
|
- ✅ E2: Intent Classification (12 SP) — DONE
|
||||||
- ⏳ E3-E5: Remaining (39 SP) — TO DO
|
- ✅ E3: P1 Blocks (23 SP) — DONE
|
||||||
|
- ⏳ E4-E5: Remaining (16 SP) — TO DO
|
||||||
|
|
||||||
**Totaal Done: 33 SP / 72 SP (46%)**
|
**Totaal Done: 56 SP / 72 SP (78%)**
|
||||||
|
|
||||||
### Sprint 3 (Huidige Sprint): Core Wiring + First Block
|
### Sprint 3 (Voltooid): Core Wiring + Blocks
|
||||||
- ✅ E2.S5: Input → Block wiring (2 SP) — DONE
|
- ✅ E2.S5: Input → Block wiring (2 SP) — DONE
|
||||||
- E3.S0: CanvasArea block rendering (2 SP) ← **KRITIEK**
|
- ✅ E3.S0: CanvasArea block rendering (2 SP) — DONE
|
||||||
- E3.S1: Block Container (2 SP)
|
- ✅ E3.S1: Block Container (2 SP) — DONE
|
||||||
- E3.S2: DagnotatieBlock (5 SP)
|
- ✅ E3.S2: DagnotatieBlock (5 SP) — DONE
|
||||||
- **Deliverable:** "notitie jan" → DagnotatieBlock werkt end-to-end
|
- **Deliverable:** "notitie jan" → DagnotatieBlock werkt end-to-end ✅
|
||||||
|
|
||||||
### Sprint 4: Remaining Blocks
|
### Sprint 4 (Voltooid): Remaining Blocks
|
||||||
- E3.S3: Patient search API (3 SP)
|
- ✅ E3.S3: Patient search API (3 SP) — DONE
|
||||||
- E3.S4: ZoekenBlock (3 SP)
|
- ✅ E3.S4: ZoekenBlock (3 SP) — DONE
|
||||||
- E3.S5: PatientContextCard (5 SP)
|
- ✅ E3.S5: PatientContextCard (5 SP) — DONE
|
||||||
- E3.S6: OverdrachtBlock (3 SP)
|
- ✅ E3.S6: OverdrachtBlock (3 SP) — DONE
|
||||||
- **Deliverable:** Alle P1 blocks werken
|
- **Deliverable:** Alle P1 blocks werken ✅
|
||||||
|
|
||||||
### Sprint 5: Polish & Ship
|
### Sprint 5: Polish & Ship
|
||||||
- E4: Navigation & Auth (8 SP)
|
- E4: Navigation & Auth (8 SP)
|
||||||
@@ -656,3 +653,4 @@ Een epic is **Done** wanneer:
|
|||||||
| v1.5 | 24-12-2024 | Claude | E2.S5 toegevoegd: Input → Block wiring (+2 SP) |
|
| v1.5 | 24-12-2024 | Claude | E2.S5 toegevoegd: Input → Block wiring (+2 SP) |
|
||||||
| **v2.0** | **24-12-2024** | **Claude** | **Major update: E3.S0 toegevoegd, technische debt sectie, diagnostiek workflow referentie, sprint planning aangepast (29 stories, 72 SP)** |
|
| **v2.0** | **24-12-2024** | **Claude** | **Major update: E3.S0 toegevoegd, technische debt sectie, diagnostiek workflow referentie, sprint planning aangepast (29 stories, 72 SP)** |
|
||||||
| **v2.1** | **24-12-2024** | **Claude** | **E2.S5 voltooid: Input → Block wiring geïmplementeerd, Epic 2 compleet (33 SP done, 46%)** |
|
| **v2.1** | **24-12-2024** | **Claude** | **E2.S5 voltooid: Input → Block wiring geïmplementeerd, Epic 2 compleet (33 SP done, 46%)** |
|
||||||
|
| **v2.2** | **24-12-2024** | **Claude** | **Epic 3 compleet: Alle P1 blocks geïmplementeerd (E3.S0-S6), PatientContextCard toegevoegd, OverdrachtBlock met AI samenvattingen (56 SP done, 78%)** |
|
||||||
|
|||||||
0
scripts/apply-organization-seed.sh
Executable file → Normal file
0
scripts/apply-organization-seed.sh
Executable file → Normal file
Reference in New Issue
Block a user