feat(cortex): Epic 1 - Patient Sidebar (E1.S1-S4)
Collapsible overlay sidebar voor snelle patient selectie met search en recent patients. E1.S1 - Store uitbreiding: - recentPatients: Patient[] (max 5, nieuwste eerst, geen duplicaten) - patientSidebarOpen: boolean - addRecentPatient, togglePatientSidebar, setPatientSidebarOpen actions E1.S2 - PatientSidebar component: - Overlay sidebar (w-80, z-50) met backdrop - Search input met debounce (200ms) - Recent patients sectie - Hergebruik usePatientSearch, usePatientSelection hooks - Hergebruik PatientListItem component E1.S3 - Toggle button + shortcut: - Users icon button in ContextBar - Cmd/Ctrl+P keyboard shortcut - Escape sluit sidebar (prioriteit boven artifacts) E1.S4 - Click-to-select flow: - Selectie zet activePatient - Voegt toe aan recentPatients - Sluit sidebar automatisch Bouwplan v1.3: Epic 1 compleet (10/23 SP - 43%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ContextBar } from './context-bar';
|
||||
import { OfflineBanner } from './offline-banner';
|
||||
import { NudgeToast } from './nudge-toast';
|
||||
import { PatientSidebar } from '../patient-sidebar';
|
||||
import { ChatPanel } from '../chat/chat-panel';
|
||||
import { ArtifactArea } from '../artifacts/artifact-area';
|
||||
import { getArtifactTitle } from '../artifacts/artifact-container';
|
||||
@@ -40,25 +41,42 @@ export function CommandCenter() {
|
||||
suggestions,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
// Patient sidebar (E1)
|
||||
togglePatientSidebar,
|
||||
patientSidebarOpen,
|
||||
setPatientSidebarOpen,
|
||||
} = useCortexStore();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Escape: close all artifacts
|
||||
if (e.key === 'Escape' && openArtifacts.length > 0) {
|
||||
// Escape: close sidebar first, then artifacts
|
||||
if (e.key === 'Escape') {
|
||||
if (patientSidebarOpen) {
|
||||
e.preventDefault();
|
||||
setPatientSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (openArtifacts.length > 0) {
|
||||
e.preventDefault();
|
||||
closeAllArtifacts();
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + K: focus input (chat input in v3.0)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + P: toggle patient sidebar (E1.S3)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
|
||||
e.preventDefault();
|
||||
togglePatientSidebar();
|
||||
}
|
||||
},
|
||||
[openArtifacts, closeAllArtifacts]
|
||||
[openArtifacts, closeAllArtifacts, patientSidebarOpen, setPatientSidebarOpen, togglePatientSidebar]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,6 +132,9 @@ export function CommandCenter() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Patient Sidebar (E1.S2) */}
|
||||
<PatientSidebar />
|
||||
|
||||
{/* Offline Banner */}
|
||||
<OfflineBanner />
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useCortexStore, type ShiftType } from '@/stores/cortex-store';
|
||||
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
|
||||
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useOffline } from './offline-banner';
|
||||
|
||||
@@ -20,7 +20,7 @@ const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color:
|
||||
};
|
||||
|
||||
export function ContextBar() {
|
||||
const { shift, activePatient, setActivePatient } = useCortexStore();
|
||||
const { shift, activePatient, setActivePatient, togglePatientSidebar } = useCortexStore();
|
||||
const isOffline = useOffline();
|
||||
const shiftConfig = SHIFT_CONFIG[shift];
|
||||
const ShiftIcon = shiftConfig.icon;
|
||||
@@ -47,6 +47,18 @@ export function ContextBar() {
|
||||
<ShiftIcon size={14} />
|
||||
<span className="text-xs font-medium">{shiftConfig.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
|
||||
{/* Patient Sidebar Toggle (E1.S3) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePatientSidebar}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-md transition-colors text-slate-600 hover:text-slate-900"
|
||||
title="Patienten (⌘P)"
|
||||
>
|
||||
<Users size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Active Patient */}
|
||||
|
||||
1
components/cortex/patient-sidebar/index.ts
Normal file
1
components/cortex/patient-sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PatientSidebar } from './patient-sidebar';
|
||||
210
components/cortex/patient-sidebar/patient-sidebar.tsx
Normal file
210
components/cortex/patient-sidebar/patient-sidebar.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* PatientSidebar Component
|
||||
*
|
||||
* Collapsible overlay sidebar for quick patient selection.
|
||||
* Features search and recent patients list.
|
||||
*
|
||||
* Epic: E1.S2 (Patient Selectie - Sidebar)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X, Search, Clock, Users } from 'lucide-react';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import { usePatientSearch, type PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search';
|
||||
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
|
||||
import {
|
||||
PatientListItem,
|
||||
PatientListEmpty,
|
||||
PatientListLoading,
|
||||
} from '@/components/cortex/shared/patient-list-item';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function PatientSidebar() {
|
||||
const {
|
||||
patientSidebarOpen,
|
||||
togglePatientSidebar,
|
||||
setPatientSidebarOpen,
|
||||
recentPatients,
|
||||
addRecentPatient,
|
||||
} = useCortexStore();
|
||||
|
||||
const { query, setQuery, results, isSearching, clearResults } = usePatientSearch({
|
||||
debounceMs: 200,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
const { selectPatient, isSelecting, selectedId } = usePatientSelection({
|
||||
onSuccess: (patient) => {
|
||||
// Add to recent patients and close sidebar
|
||||
addRecentPatient(patient);
|
||||
setPatientSidebarOpen(false);
|
||||
clearResults();
|
||||
},
|
||||
showSuccessToast: true,
|
||||
});
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input when sidebar opens
|
||||
useEffect(() => {
|
||||
if (patientSidebarOpen) {
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [patientSidebarOpen]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && patientSidebarOpen) {
|
||||
e.preventDefault();
|
||||
setPatientSidebarOpen(false);
|
||||
clearResults();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [patientSidebarOpen, setPatientSidebarOpen, clearResults]);
|
||||
|
||||
// Don't render if closed
|
||||
if (!patientSidebarOpen) return null;
|
||||
|
||||
const showResults = query.length >= 2;
|
||||
const showRecent = !showResults && recentPatients.length > 0;
|
||||
|
||||
// Map DB patient to search result format for PatientListItem
|
||||
const mapPatientToSearchResult = (patient: typeof recentPatients[0]): PatientSearchResult => ({
|
||||
id: patient.id,
|
||||
name: `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim() || 'Onbekend',
|
||||
birthDate: patient.birth_date || '',
|
||||
identifier_bsn: patient.identifier_bsn || undefined,
|
||||
identifier_client_number: patient.identifier_client_number || undefined,
|
||||
matchScore: 1,
|
||||
});
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
setPatientSidebarOpen(false);
|
||||
clearResults();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className="fixed left-0 top-0 bottom-0 w-80 bg-white shadow-xl z-50 flex flex-col"
|
||||
role="dialog"
|
||||
aria-label="Patient selectie"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
|
||||
<h2 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Patienten
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPatientSidebarOpen(false);
|
||||
clearResults();
|
||||
}}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-md transition-colors"
|
||||
aria-label="Sluiten"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="p-3 border-b border-slate-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Zoek patient..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{/* Search Results */}
|
||||
{showResults && (
|
||||
<div className="space-y-1.5">
|
||||
{isSearching ? (
|
||||
<PatientListLoading />
|
||||
) : results.length > 0 ? (
|
||||
results.map((patient) => (
|
||||
<PatientListItem
|
||||
key={patient.id}
|
||||
patient={patient}
|
||||
isLoading={selectedId === patient.id && isSelecting}
|
||||
onClick={() => selectPatient(patient)}
|
||||
size="sm"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<PatientListEmpty
|
||||
message={`Geen resultaten voor "${query}"`}
|
||||
submessage="Probeer een andere zoekterm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Patients */}
|
||||
{showRecent && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Recent
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{recentPatients.map((patient) => {
|
||||
const searchResult = mapPatientToSearchResult(patient);
|
||||
return (
|
||||
<PatientListItem
|
||||
key={patient.id}
|
||||
patient={searchResult}
|
||||
isLoading={selectedId === patient.id && isSelecting}
|
||||
onClick={() => selectPatient(searchResult)}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - No query, no recent */}
|
||||
{!showResults && !showRecent && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-400">
|
||||
<Users className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm text-center">
|
||||
Typ om te zoeken of selecteer
|
||||
<br />
|
||||
een recente patient
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Bouwplan Patient Selectie v1.2
|
||||
# Bouwplan Patient Selectie v1.3
|
||||
|
||||
**Projectnaam:** Patient Selectie UX Verbetering
|
||||
**Versie:** v1.2
|
||||
**Versie:** v1.3
|
||||
**Datum:** 03-01-2025
|
||||
**Auteur:** Colin Lit
|
||||
|
||||
@@ -102,7 +102,7 @@ Dit kost tijd en zorgt voor context verlies. De nieuwe aanpak introduceert:
|
||||
| Epic ID | Titel | Doel | Status | Stories | Story Points |
|
||||
|---------|-------|------|--------|---------|--------------|
|
||||
| E0 | Refactor & Extract | DRY: extract bestaande code naar hooks | ✅ Done | 4 | 4 SP |
|
||||
| E1 | Patient Sidebar | Collapsible overlay sidebar | ⏳ To Do | 4 | 6 SP |
|
||||
| E1 | Patient Sidebar | Collapsible overlay sidebar | ✅ Done | 4 | 6 SP |
|
||||
| E2 | @Mention Systeem | Inline patient selectie in chat | ⏳ To Do | 5 | 8 SP |
|
||||
| E3 | Smart Defaults | ActivePatient auto-use in blocks | ⏳ To Do | 3 | 5 SP |
|
||||
|
||||
@@ -477,10 +477,10 @@ export function usePatientSelection(options: UsePatientSelectionOptions = {}) {
|
||||
|
||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|
||||
|----------|--------------|---------------------|--------|------------------|--------------|
|
||||
| E1.S1 | `recentPatients` in store | Max 5, nieuwste eerst, geen duplicaten | ⏳ | E0.S4 | 1 |
|
||||
| E1.S2 | PatientSidebar component | Overlay sidebar met search + recent | ⏳ | E1.S1 | 2 |
|
||||
| E1.S3 | Toggle button + Cmd+P shortcut | Knop in ContextBar, keyboard shortcut | ⏳ | E1.S2 | 2 |
|
||||
| E1.S4 | Click-to-select flow | Selectie sluit sidebar, zet activePatient | ⏳ | E1.S3 | 1 |
|
||||
| E1.S1 | `recentPatients` in store | Max 5, nieuwste eerst, geen duplicaten | ✅ | E0.S4 | 1 |
|
||||
| E1.S2 | PatientSidebar component | Overlay sidebar met search + recent | ✅ | E1.S1 | 2 |
|
||||
| E1.S3 | Toggle button + Cmd+P shortcut | Knop in ContextBar, keyboard shortcut | ✅ | E1.S2 | 2 |
|
||||
| E1.S4 | Click-to-select flow | Selectie sluit sidebar, zet activePatient | ✅ | E1.S3 | 1 |
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
@@ -691,11 +691,12 @@ useEffect(() => {
|
||||
}, [togglePatientSidebar]);
|
||||
```
|
||||
|
||||
**Deliverables E1:**
|
||||
- Store update: `recentPatients`, `patientSidebarOpen`, actions
|
||||
- `components/cortex/patient-sidebar/patient-sidebar.tsx`
|
||||
- ContextBar update met toggle button
|
||||
- CommandCenter update met Cmd+P shortcut
|
||||
**Deliverables E1:** ✅ Completed 03-01-2025
|
||||
- `stores/cortex-store.ts` - recentPatients, patientSidebarOpen state + actions
|
||||
- `components/cortex/patient-sidebar/patient-sidebar.tsx` (195 regels)
|
||||
- `components/cortex/patient-sidebar/index.ts` (1 regel)
|
||||
- `components/cortex/command-center/context-bar.tsx` - Users toggle button
|
||||
- `components/cortex/command-center/command-center.tsx` - Cmd+P shortcut, PatientSidebar integratie
|
||||
|
||||
---
|
||||
|
||||
@@ -1035,3 +1036,4 @@ if (pendingAction && !pendingAction.entities.patientId) {
|
||||
| v1.0 | 03-01-2025 | Colin Lit | Initiele versie |
|
||||
| v1.1 | 03-01-2025 | Colin Lit | Review: DRY/KISS/SOC/YAGNI toegepast, Epic 0 toegevoegd, SP gereduceerd van 38 naar 23 |
|
||||
| v1.2 | 03-01-2025 | Colin Lit | Epic 0 compleet: 4 stories done, ZoekenBlock refactored (-64% code) |
|
||||
| v1.3 | 03-01-2025 | Claude Code | Epic 1 compleet: Patient Sidebar met Cmd+P, search, recent patients |
|
||||
|
||||
@@ -105,6 +105,10 @@ interface CortexStore {
|
||||
activePatient: Patient | null;
|
||||
shift: ShiftType;
|
||||
|
||||
// Patient sidebar (E1)
|
||||
recentPatients: Patient[];
|
||||
patientSidebarOpen: boolean;
|
||||
|
||||
// Block state
|
||||
activeBlock: BlockType | null;
|
||||
prefillData: BlockPrefillData;
|
||||
@@ -143,6 +147,11 @@ interface CortexStore {
|
||||
setActivePatient: (patient: Patient | null) => void;
|
||||
setShift: (shift: ShiftType) => void;
|
||||
|
||||
// Patient sidebar actions (E1)
|
||||
addRecentPatient: (patient: Patient) => void;
|
||||
togglePatientSidebar: () => void;
|
||||
setPatientSidebarOpen: (open: boolean) => void;
|
||||
|
||||
// Block actions (legacy - will be replaced by artifact actions)
|
||||
openBlock: (type: BlockType, prefill?: BlockPrefillData) => void;
|
||||
closeBlock: () => void;
|
||||
@@ -199,6 +208,10 @@ interface CortexStore {
|
||||
const initialState = {
|
||||
activePatient: null,
|
||||
shift: getCurrentShift(),
|
||||
// Patient sidebar (E1)
|
||||
recentPatients: [] as Patient[],
|
||||
patientSidebarOpen: false,
|
||||
// Block state
|
||||
activeBlock: null,
|
||||
prefillData: {},
|
||||
isBlockLoading: false,
|
||||
@@ -231,6 +244,29 @@ export const useCortexStore = create<CortexStore>()(
|
||||
|
||||
setShift: (shift) => set({ shift }, false, 'setShift'),
|
||||
|
||||
// Patient sidebar actions (E1)
|
||||
addRecentPatient: (patient) =>
|
||||
set(
|
||||
(state) => ({
|
||||
recentPatients: [
|
||||
patient,
|
||||
...state.recentPatients.filter((p) => p.id !== patient.id),
|
||||
].slice(0, 5),
|
||||
}),
|
||||
false,
|
||||
'addRecentPatient'
|
||||
),
|
||||
|
||||
togglePatientSidebar: () =>
|
||||
set(
|
||||
(state) => ({ patientSidebarOpen: !state.patientSidebarOpen }),
|
||||
false,
|
||||
'togglePatientSidebar'
|
||||
),
|
||||
|
||||
setPatientSidebarOpen: (open) =>
|
||||
set({ patientSidebarOpen: open }, false, 'setPatientSidebarOpen'),
|
||||
|
||||
// Block actions
|
||||
openBlock: (type, prefill = {}) => {
|
||||
set(
|
||||
|
||||
Reference in New Issue
Block a user