From 2a67a0a2d9ffc4f0c8a1ef645b4b2da2b2833b8d Mon Sep 17 00:00:00 2001 From: ikbenlit Date: Wed, 24 Dec 2025 09:06:39 +0100 Subject: [PATCH] =?UTF-8?q?git=20commit=20-m=20"feat(swift):=20implement?= =?UTF-8?q?=20Epic=203=20-=20P1=20Blocks=20(E3.S0-S6)"=20-m=20"Epic=203=20?= =?UTF-8?q?compleet:=20Alle=20P1=20blocks=20ge=C3=AFmplementeerd=20met=20v?= =?UTF-8?q?olledige=20functionaliteit."=20-m=20"E3.S0=20-=20CanvasArea=20b?= =?UTF-8?q?lock=20rendering"=20-m=20"-=20Switch/case=20voor=20block=20type?= =?UTF-8?q?s=20met=20prefill=20data"=20-m=20"-=20AnimatePresence=20voor=20?= =?UTF-8?q?smooth=20transitions"=20-m=20"-=20PatientContextCard=20auto-dis?= =?UTF-8?q?play=20wanneer=20activePatient=20is=20gezet"=20-m=20""=20-m=20"?= =?UTF-8?q?E3.S1=20-=20Block=20Container=20animaties"=20-m=20"-=20Framer?= =?UTF-8?q?=20Motion=20animaties=20voor=20container,=20content=20en=20clos?= =?UTF-8?q?e=20button"=20-m=20"-=20Stagger=20effecten=20voor=20content=20c?= =?UTF-8?q?hildren"=20-m=20"-=20Hover/tap=20animaties=20voor=20close=20but?= =?UTF-8?q?ton"=20-m=20""=20-m=20"E3.S2=20-=20DagnotatieBlock"=20-m=20"-?= =?UTF-8?q?=20Patient=20search=20met=20debounced=20FHIR=20API=20integratie?= =?UTF-8?q?"=20-m=20"-=20Category=20selector=20(medicatie,=20adl,=20gedrag?= =?UTF-8?q?,=20incident,=20observatie)"=20-m=20"-=20Textarea=20met=20chara?= =?UTF-8?q?cter=20counter=20(max=20500)"=20-m=20"-=20Opslaan=20naar=20/api?= =?UTF-8?q?/reports=20met=20validatie=20en=20error=20handling"=20-m=20""?= =?UTF-8?q?=20-m=20"E3.S3=20-=20Patient=20search=20API"=20-m=20"-=20GET=20?= =?UTF-8?q?/api/patients/search=3Fq=3D=20met=20fuzzy=20search"=20-m=20"-?= =?UTF-8?q?=20Match=20score=20berekening=20voor=20resultaten"=20-m=20"-=20?= =?UTF-8?q?Auth=20check=20en=20error=20handling"=20-m=20""=20-m=20"E3.S4?= =?UTF-8?q?=20-=20ZoekenBlock"=20-m=20"-=20Debounced=20patient=20search=20?= =?UTF-8?q?met=20dropdown"=20-m=20"-=20Patient=20selectie=20=E2=86=92=20se?= =?UTF-8?q?tActivePatient=20in=20store"=20-m=20"-=20Auto-close=20na=20sele?= =?UTF-8?q?ctie=20+=20recent=20action"=20-m=20""=20-m=20"E3.S5=20-=20Patie?= =?UTF-8?q?ntContextCard"=20-m=20"-=20Auto-open=20na=20patient=20selectie"?= =?UTF-8?q?=20-m=20"-=20Notities,=20vitals,=20diagnoses=20en=20risico's=20?= =?UTF-8?q?secties"=20-m=20"-=20API=20integratie=20met=20/api/overdracht/[?= =?UTF-8?q?patientId]"=20-m=20""=20-m=20"E3.S6=20-=20OverdrachtBlock"=20-m?= =?UTF-8?q?=20"-=20Lijst=20van=20pati=C3=ABnten=20met=20activiteit"=20-m?= =?UTF-8?q?=20"-=20Period=20selector=20(1d,=203d,=207d,=2014d)"=20-m=20"-?= =?UTF-8?q?=20AI=20samenvatting=20generatie=20per=20pati=C3=ABnt"=20-m=20"?= =?UTF-8?q?-=20Aandachtspunten=20en=20actiepunten=20weergave"=20-m=20""=20?= =?UTF-8?q?-m=20"Technische=20verbeteringen:"=20-m=20"-=20Technische=20deb?= =?UTF-8?q?t=20opgelost:=20BlockContainer=20animaties,=20CanvasArea=20rend?= =?UTF-8?q?ering"=20-m=20"-=20PatientContextCard=20toegevoegd=20aan=20bloc?= =?UTF-8?q?ks"=20-m=20"-=20Alle=20blocks=20gebruiken=20BlockContainer=20vo?= =?UTF-8?q?or=20consistente=20styling"=20-m=20""=20-m=20"Voortgang:=2056?= =?UTF-8?q?=20SP=20/=2072=20SP=20(78%)=20-=20Epic=203=20compleet"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/patients/search/route.ts | 197 ++++++++ components/swift/blocks/block-container.tsx | 69 ++- components/swift/blocks/dagnotitie-block.tsx | 383 +++++++++++++++ components/swift/blocks/index.ts | 7 +- components/swift/blocks/overdracht-block.tsx | 441 ++++++++++++++++++ .../swift/blocks/patient-context-card.tsx | 261 +++++++++++ components/swift/blocks/zoeken-block.tsx | 334 +++++++++++++ .../swift/command-center/canvas-area.tsx | 55 ++- docs/swift/bouwplan-swift-v2.md | 140 +++--- scripts/apply-organization-seed.sh | 0 10 files changed, 1800 insertions(+), 87 deletions(-) create mode 100644 app/api/patients/search/route.ts create mode 100644 components/swift/blocks/dagnotitie-block.tsx create mode 100644 components/swift/blocks/overdracht-block.tsx create mode 100644 components/swift/blocks/patient-context-card.tsx create mode 100644 components/swift/blocks/zoeken-block.tsx mode change 100755 => 100644 scripts/apply-organization-seed.sh diff --git a/app/api/patients/search/route.ts b/app/api/patients/search/route.ts new file mode 100644 index 0000000..21085ef --- /dev/null +++ b/app/api/patients/search/route.ts @@ -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( + { + 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( + { + 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 } + ); + } +} + diff --git a/components/swift/blocks/block-container.tsx b/components/swift/blocks/block-container.tsx index 37a4048..458c448 100644 --- a/components/swift/blocks/block-container.tsx +++ b/components/swift/blocks/block-container.tsx @@ -7,6 +7,7 @@ */ import { ReactNode } from 'react'; +import { motion, type Variants } from 'framer-motion'; import { X } from 'lucide-react'; import { useSwiftStore } from '@/stores/swift-store'; import type { BlockSize } from '@/lib/swift/types'; @@ -24,27 +25,83 @@ const SIZE_CLASSES: Record = { 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) { const { closeBlock } = useSwiftStore(); return ( -
{/* Header */}
-

{title}

- +
{/* Content */} -
{children}
-
+ + {children} + + ); } diff --git a/components/swift/blocks/dagnotitie-block.tsx b/components/swift/blocks/dagnotitie-block.tsx new file mode 100644 index 0000000..48f53bf --- /dev/null +++ b/components/swift/blocks/dagnotitie-block.tsx @@ -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(prefill?.patientId || ''); + const [patientName, setPatientName] = useState(prefill?.patientName || ''); + const [category, setCategory] = useState( + prefill?.category || 'observatie' + ); + const [content, setContent] = useState(prefill?.content || ''); + const [includeInHandover, setIncludeInHandover] = useState(false); + + // Patient search state + const [searchQuery, setSearchQuery] = useState(prefill?.patientName || ''); + const [patients, setPatients] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showPatientDropdown, setShowPatientDropdown] = useState(false); + const [selectedPatient, setSelectedPatient] = useState(null); + const searchTimeoutRef = useRef(); + const dropdownRef = useRef(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 ( + +
+ {/* Patient Selectie */} +
+ +
+ {selectedPatient ? ( +
+ + {formatPatientName(selectedPatient)} + +
+ ) : ( + <> +
+ + { + setSearchQuery(e.target.value); + setShowPatientDropdown(true); + }} + onFocus={() => { + if (patients.length > 0) { + setShowPatientDropdown(true); + } + }} + className="pl-9" + /> + {isSearching && ( + + )} +
+ {showPatientDropdown && patients.length > 0 && ( +
+ {patients.map((patient) => ( + + ))} +
+ )} + + )} +
+
+ + {/* Categorie Selector */} +
+ +
+ {VERPLEEGKUNDIG_CATEGORIES.map((cat) => { + const catConfig = CATEGORY_CONFIG[cat]; + const isSelected = category === cat; + return ( + + ); + })} +
+
+ + {/* Content */} +
+ +