refactor: rename swift → cortex in code and documentation
Complete rename of "swift" terminology to "cortex" across the codebase: Code Changes: - Rename directories: lib/swift → lib/cortex, components/swift → components/cortex - Rename API routes: /api/swift/* → /api/cortex/* - Rename page routes: /epd/swift → /epd/cortex - Rename store: swift-store.ts → cortex-store.ts Type Renames: - SwiftIntent → CortexIntent - SwiftStore → CortexStore - useSwiftStore → useCortexStore - SwiftContext → CortexContext - useSwiftVoice → useCortexVoice Documentation Updates (docs/intent/): - Safety Net → Nudge (Layer 3 rename) - SafetySuggestion → NudgeSuggestion - evaluateSafetyNet → evaluateNudge - SWIFT_* feature flags → CORTEX_* - All path references updated to match new structure Files affected: 47 files, ~700 lines changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
146
components/cortex/command-center/canvas-area.tsx
Normal file
146
components/cortex/command-center/canvas-area.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Canvas Area
|
||||
*
|
||||
* Central area where blocks appear. Shows empty state when no block is active.
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import type { BlockType, BlockPrefillData } from '@/stores/cortex-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';
|
||||
import { FallbackPicker } from '../blocks/fallback-picker';
|
||||
|
||||
export function CanvasArea() {
|
||||
const { activeBlock, prefillData, activePatient } = useCortexStore();
|
||||
|
||||
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} />;
|
||||
case 'fallback':
|
||||
return <FallbackPicker originalInput={prefill.content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Block animations volgens UX specificatie (sectie 11.1):
|
||||
// Openen: Slide up + fade in (200ms), Scale: 0.95 → 1.0
|
||||
// Sluiten: Slide down + fade out (200ms), Scale: 1.0 → 0.95
|
||||
const blockAnimations = {
|
||||
initial: { opacity: 0, y: 20, scale: 0.95 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 20, // Slide down (niet omhoog)
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{activeBlock ? (
|
||||
<motion.div
|
||||
key={activeBlock}
|
||||
initial={blockAnimations.initial}
|
||||
animate={blockAnimations.animate}
|
||||
exit={blockAnimations.exit}
|
||||
>
|
||||
{renderBlock(activeBlock, prefillData)}
|
||||
</motion.div>
|
||||
) : activePatient ? (
|
||||
<motion.div
|
||||
key="patient-context"
|
||||
initial={blockAnimations.initial}
|
||||
animate={blockAnimations.animate}
|
||||
exit={blockAnimations.exit}
|
||||
>
|
||||
<PatientContextCard />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<EmptyState />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium text-slate-700 mb-2">Wat wil je doen?</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Typ of spreek je intentie
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-left">
|
||||
<ExampleCommand icon="📝" text="notitie jan medicatie gegeven" />
|
||||
<ExampleCommand icon="🔍" text="zoek marie" />
|
||||
<ExampleCommand icon="📋" text="overdracht" />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-slate-500">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-white border border-slate-200 text-slate-600 shadow-sm">⌘K</kbd> om te focussen
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExampleCommand({ icon, text }: { icon: string; text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white border border-slate-200 shadow-sm text-sm">
|
||||
<span>{icon}</span>
|
||||
<span className="text-slate-600">"{text}"</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
components/cortex/command-center/command-center.tsx
Normal file
123
components/cortex/command-center/command-center.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Command Center (v3.0)
|
||||
*
|
||||
* Main container for the Cortex interface.
|
||||
* Split-screen layout: Chat Panel (40%) | Artifact Area (60%)
|
||||
*
|
||||
* Layout specs:
|
||||
* - Context Bar: 48px (h-12) - UNCHANGED
|
||||
* - Split container: flex-1 (fills remaining space)
|
||||
* - Chat Panel: 40% width (desktop), 100% (mobile)
|
||||
* - Artifact Area: 60% width (desktop), 100% (mobile)
|
||||
*
|
||||
* Epic: E1 (Foundation)
|
||||
* Stories: E1.S2 (Split-screen layout), E1.S3 (Placeholders), E1.S4 (Responsive)
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import { ContextBar } from './context-bar';
|
||||
import { OfflineBanner } from './offline-banner';
|
||||
import { ChatPanel } from '../chat/chat-panel';
|
||||
import { ArtifactArea } from '../artifacts/artifact-area';
|
||||
import { getArtifactTitle } from '../artifacts/artifact-container';
|
||||
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
|
||||
|
||||
export function CommandCenter() {
|
||||
const { closeAllArtifacts, openArtifacts, openArtifact, pendingAction, setPendingAction } = 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) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
[openArtifacts, closeAllArtifacts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// E3.S6 + E4.S2: Handle pending actions from chat (artifact opening)
|
||||
useEffect(() => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
console.log('[CommandCenter] Processing pending action:', pendingAction);
|
||||
|
||||
// Check if action has artifact data
|
||||
if (pendingAction.artifact) {
|
||||
const { type, prefill } = pendingAction.artifact;
|
||||
|
||||
// Generate title for the artifact
|
||||
const title = getArtifactTitle(type, prefill);
|
||||
|
||||
console.log('[CommandCenter] Opening artifact:', type, title);
|
||||
|
||||
// Open the artifact (E4.S2 - new artifact system)
|
||||
openArtifact({
|
||||
type,
|
||||
prefill,
|
||||
title,
|
||||
});
|
||||
|
||||
setPendingAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const routedArtifact = routeIntentToArtifact(
|
||||
pendingAction.intent,
|
||||
pendingAction.entities,
|
||||
pendingAction.confidence
|
||||
);
|
||||
|
||||
if (routedArtifact) {
|
||||
console.log('[CommandCenter] Routing action to artifact:', routedArtifact.type);
|
||||
openArtifact({
|
||||
type: routedArtifact.type,
|
||||
prefill: routedArtifact.prefill,
|
||||
title: routedArtifact.title,
|
||||
});
|
||||
} else {
|
||||
console.log('[CommandCenter] Action has no artifact, skipping');
|
||||
}
|
||||
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, openArtifact, setPendingAction]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Offline Banner */}
|
||||
<OfflineBanner />
|
||||
|
||||
{/* Context Bar - 48px (unchanged) */}
|
||||
<ContextBar />
|
||||
|
||||
{/* Split-screen container - flex-1 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Chat Panel - 40% (desktop), 100% (mobile) */}
|
||||
<div className="w-full lg:w-[40%] border-r border-slate-200 flex flex-col">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
|
||||
{/* Artifact Area - 60% (desktop), hidden on mobile */}
|
||||
<div className="hidden lg:flex lg:w-[60%] flex-col">
|
||||
<ArtifactArea />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
components/cortex/command-center/command-input.tsx
Normal file
315
components/cortex/command-center/command-input.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Command Input
|
||||
*
|
||||
* Bottom input bar for text and voice commands.
|
||||
* Height: 64px (h-16)
|
||||
*
|
||||
* Features:
|
||||
* - Text input with dynamic placeholder
|
||||
* - Focus state with ring
|
||||
* - Send button (appears when input has value)
|
||||
* - Voice input with Deepgram streaming
|
||||
* - ⌘K shortcut hint
|
||||
*/
|
||||
|
||||
import { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import { useCortexVoice } from '@/lib/cortex/use-cortex-voice';
|
||||
import type { BlockType } from '@/lib/cortex/types';
|
||||
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
|
||||
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
|
||||
|
||||
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
|
||||
const {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
clearInput,
|
||||
activePatient,
|
||||
activeBlock,
|
||||
isVoiceActive,
|
||||
openBlock,
|
||||
openArtifact,
|
||||
addRecentAction,
|
||||
} = useCortexStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isConnecting,
|
||||
isConnected,
|
||||
error: voiceError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
analyserNode,
|
||||
isBrowserSupported,
|
||||
} = useCortexVoice();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const waveformRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
const hasValue = inputValue.trim().length > 0;
|
||||
|
||||
// Waveform visualization
|
||||
useEffect(() => {
|
||||
if (!analyserNode || !waveformRef.current || !isRecording) {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = waveformRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const bufferLength = analyserNode.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const draw = () => {
|
||||
if (!isRecording) return;
|
||||
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
analyserNode.getByteFrequencyData(dataArray);
|
||||
|
||||
ctx.fillStyle = 'rgb(248, 250, 252)'; // slate-50
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * canvas.height;
|
||||
|
||||
// Gradient from blue to red based on amplitude
|
||||
const hue = 220 - (dataArray[i] / 255) * 40;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
|
||||
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||
x += barWidth + 1;
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [analyserNode, isRecording]);
|
||||
|
||||
// Dynamic placeholder based on context
|
||||
const getPlaceholder = () => {
|
||||
if (isRecording) return 'Luisteren...';
|
||||
if (isConnecting) return 'Verbinden met spraakherkenning...';
|
||||
if (activePatient) {
|
||||
return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`;
|
||||
}
|
||||
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!hasValue || isProcessing) return;
|
||||
|
||||
// Stop recording if active
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
|
||||
const inputText = inputValue.trim();
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Call intent classification API
|
||||
const response = await safeFetch(
|
||||
'/api/intent/classify',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: inputText }),
|
||||
},
|
||||
{ operation: 'Intent classificeren' }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
const { intent, confidence, entities } = result;
|
||||
|
||||
// Route intent to artifact using Epic 5.S1 routing logic
|
||||
const artifactConfig = routeIntentToArtifact(intent, entities, confidence);
|
||||
|
||||
if (artifactConfig) {
|
||||
// Open artifact with routing configuration
|
||||
openArtifact({
|
||||
type: artifactConfig.type,
|
||||
title: artifactConfig.title,
|
||||
prefill: artifactConfig.prefill,
|
||||
});
|
||||
|
||||
// Add to recent actions
|
||||
addRecentAction({
|
||||
intent,
|
||||
label: inputText.slice(0, 50), // Truncate for display
|
||||
patientName: entities.patientName,
|
||||
});
|
||||
|
||||
clearInput();
|
||||
} else {
|
||||
// Low confidence or missing required entities - show FallbackPicker
|
||||
openBlock('fallback', { content: inputText });
|
||||
clearInput();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing intent:', error);
|
||||
const statusCode = (error as any)?.statusCode;
|
||||
const errorInfo = getErrorInfo(error, {
|
||||
operation: 'Intent classificeren',
|
||||
statusCode,
|
||||
});
|
||||
|
||||
// Show error toast
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: errorInfo.title,
|
||||
description: errorInfo.description,
|
||||
});
|
||||
|
||||
// On error, show FallbackPicker so user can choose
|
||||
// This ensures the user's input is not lost
|
||||
openBlock('fallback', { content: inputText });
|
||||
clearInput();
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceToggle = () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+Enter to submit
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter: submit command
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSubmit]);
|
||||
|
||||
const isDisabled = isProcessing || activeBlock !== null;
|
||||
|
||||
return (
|
||||
<footer className="h-16 border-t border-slate-200 flex items-center px-4 shrink-0 bg-white">
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
|
||||
{/* Input wrapper with optional waveform */}
|
||||
<div className="relative flex-1">
|
||||
{/* Waveform canvas (shown when recording) */}
|
||||
{isRecording && (
|
||||
<canvas
|
||||
ref={waveformRef}
|
||||
width={200}
|
||||
height={40}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded opacity-60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
disabled={isDisabled}
|
||||
className={`w-full bg-white border rounded-xl py-3 text-slate-900 placeholder:text-slate-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
${isRecording ? 'pl-[220px] pr-12 border-red-500/50' : 'pl-4 pr-12 border-slate-300'}`}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Status indicators */}
|
||||
{!hasValue && !isRecording && !isConnecting && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-600 pointer-events-none hidden sm:block">
|
||||
⌘K
|
||||
</span>
|
||||
)}
|
||||
{isConnecting && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-500 flex items-center gap-1">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Verbinden
|
||||
</span>
|
||||
)}
|
||||
{voiceError && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-red-400 max-w-[150px] truncate">
|
||||
{voiceError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send button - appears when has value */}
|
||||
{hasValue && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing}
|
||||
className="p-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Verstuur (Enter)"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={20} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice button */}
|
||||
{isBrowserSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVoiceToggle}
|
||||
disabled={isDisabled || isConnecting}
|
||||
className={`p-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isRecording
|
||||
? 'bg-red-600 hover:bg-red-500 text-white ring-4 ring-red-600/30'
|
||||
: 'bg-slate-100 hover:bg-slate-200 text-slate-600'
|
||||
}`}
|
||||
title={isRecording ? 'Stop opname' : 'Start voice input'}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : isRecording ? (
|
||||
<MicOff size={20} />
|
||||
) : (
|
||||
<Mic size={20} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="p-3 rounded-xl bg-slate-100 text-slate-400 cursor-not-allowed"
|
||||
title="Spraakherkenning niet ondersteund in deze browser"
|
||||
>
|
||||
<MicOff size={20} />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
85
components/cortex/command-center/context-bar.tsx
Normal file
85
components/cortex/command-center/context-bar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Context Bar
|
||||
*
|
||||
* Top bar showing current context: shift, selected patient, user info.
|
||||
* Height: 48px (h-12)
|
||||
*/
|
||||
|
||||
import { useCortexStore, type ShiftType } from '@/stores/cortex-store';
|
||||
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useOffline } from './offline-banner';
|
||||
|
||||
const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color: string }> = {
|
||||
nacht: { icon: Moon, label: 'Nachtdienst', color: 'text-indigo-600' },
|
||||
ochtend: { icon: Sunrise, label: 'Ochtenddienst', color: 'text-amber-600' },
|
||||
middag: { icon: Sun, label: 'Middagdienst', color: 'text-yellow-600' },
|
||||
avond: { icon: Sunset, label: 'Avonddienst', color: 'text-orange-600' },
|
||||
};
|
||||
|
||||
export function ContextBar() {
|
||||
const { shift, activePatient, setActivePatient } = useCortexStore();
|
||||
const isOffline = useOffline();
|
||||
const shiftConfig = SHIFT_CONFIG[shift];
|
||||
const ShiftIcon = shiftConfig.icon;
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white"
|
||||
style={isOffline ? { marginTop: '40px' } : undefined}
|
||||
>
|
||||
{/* Left: Logo + Shift */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/epd/clients"
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
title="Terug naar EPD"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm font-semibold tracking-tight">Terug naar EPD</span>
|
||||
</Link>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
|
||||
<div className={`flex items-center gap-1.5 ${shiftConfig.color}`}>
|
||||
<ShiftIcon size={14} />
|
||||
<span className="text-xs font-medium">{shiftConfig.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Active Patient */}
|
||||
<div className="flex items-center">
|
||||
{activePatient ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100 border border-slate-300">
|
||||
<div className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-[10px] font-medium text-white">
|
||||
{activePatient.name_given[0]?.[0]}
|
||||
{activePatient.name_family[0]}
|
||||
</div>
|
||||
<span className="text-sm text-slate-900">
|
||||
{activePatient.name_given.join(' ')} {activePatient.name_family}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActivePatient(null)}
|
||||
className="p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
title="Patiënt deselecteren"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">Geen patiënt geselecteerd</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: User */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<User size={16} />
|
||||
<span className="text-xs hidden sm:block">Verpleegkundige</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
9
components/cortex/command-center/index.ts
Normal file
9
components/cortex/command-center/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Command Center Barrel Export
|
||||
*/
|
||||
|
||||
export { CommandCenter } from './command-center';
|
||||
export { CommandInput } from './command-input';
|
||||
export { ContextBar } from './context-bar';
|
||||
export { CanvasArea } from './canvas-area';
|
||||
export { RecentStrip } from './recent-strip';
|
||||
71
components/cortex/command-center/offline-banner.tsx
Normal file
71
components/cortex/command-center/offline-banner.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Offline Banner
|
||||
*
|
||||
* Toont een banner wanneer de gebruiker offline is.
|
||||
* E5.S2: Offline detection en melding.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial check
|
||||
setIsOffline(!navigator.onLine);
|
||||
|
||||
// Listen for online/offline events
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 z-[100] bg-amber-500 text-white px-4 py-2',
|
||||
'flex items-center justify-center gap-2 text-sm font-medium',
|
||||
'shadow-md'
|
||||
)}
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
<WifiOff className="h-4 w-4" />
|
||||
<span>Geen internetverbinding</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook om te checken of de gebruiker offline is
|
||||
*/
|
||||
export function useOffline() {
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOffline(!navigator.onLine);
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOffline;
|
||||
}
|
||||
|
||||
115
components/cortex/command-center/recent-strip.tsx
Normal file
115
components/cortex/command-center/recent-strip.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Recent Strip
|
||||
*
|
||||
* Shows last 5 actions as clickable chips for quick repeat.
|
||||
* Height: 48px (h-12)
|
||||
*/
|
||||
|
||||
import { useCortexStore, type CortexIntent } from '@/stores/cortex-store';
|
||||
import { FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X } from 'lucide-react';
|
||||
|
||||
const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string; label: string }> = {
|
||||
dagnotitie: { icon: FileText, color: 'text-blue-600 bg-blue-50 border border-blue-200', label: 'Notitie' },
|
||||
zoeken: { icon: Search, color: 'text-emerald-600 bg-emerald-50 border border-emerald-200', label: 'Zoeken' },
|
||||
overdracht: { icon: ArrowRightLeft, color: 'text-purple-600 bg-purple-50 border border-purple-200', label: 'Overdracht' },
|
||||
agenda_query: { icon: Calendar, color: 'text-teal-600 bg-teal-50 border border-teal-200', label: 'Agenda' },
|
||||
create_appointment: { icon: Plus, color: 'text-green-600 bg-green-50 border border-green-200', label: 'Afspraak' },
|
||||
cancel_appointment: { icon: X, color: 'text-red-600 bg-red-50 border border-red-200', label: 'Annuleren' },
|
||||
reschedule_appointment: { icon: Clock, color: 'text-amber-600 bg-amber-50 border border-amber-200', label: 'Verzetten' },
|
||||
unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
|
||||
};
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'zojuist';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}u`;
|
||||
return `${Math.floor(diffHours / 24)}d`;
|
||||
}
|
||||
|
||||
export function RecentStrip() {
|
||||
const { recentActions, setInputValue, openBlock } = useCortexStore();
|
||||
|
||||
const handleActionClick = (action: typeof recentActions[0]) => {
|
||||
// Set the input to repeat the action
|
||||
setInputValue(action.label);
|
||||
|
||||
// If it's a known intent, open the block directly
|
||||
if (action.intent !== 'unknown') {
|
||||
openBlock(action.intent, {
|
||||
patientName: action.patientName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-12 border-t border-slate-200 flex items-center px-4 gap-3 shrink-0 bg-white">
|
||||
{/* Label */}
|
||||
<div className="flex items-center gap-1.5 text-slate-500 shrink-0">
|
||||
<Clock size={12} />
|
||||
<span className="text-xs font-medium">Recent</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
|
||||
{/* Actions */}
|
||||
{recentActions.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">Nog geen acties</span>
|
||||
) : (
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{recentActions.map((action) => {
|
||||
const config = INTENT_CONFIG[action.intent];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => handleActionClick(action)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium
|
||||
whitespace-nowrap transition-all duration-200
|
||||
hover:scale-105 active:scale-95
|
||||
${config.color} hover:brightness-110`}
|
||||
title={`Herhaal: ${action.label}${action.patientName ? ` (${action.patientName})` : ''}`}
|
||||
>
|
||||
<Icon size={12} />
|
||||
<span className="max-w-[120px] truncate">{action.label}</span>
|
||||
{action.patientName && (
|
||||
<span className="text-[10px] opacity-60">• {action.patientName}</span>
|
||||
)}
|
||||
<span className="text-[10px] opacity-40 ml-1">
|
||||
{formatRelativeTime(action.timestamp)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions hint (when empty) */}
|
||||
{recentActions.length === 0 && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>Probeer:</span>
|
||||
<button
|
||||
onClick={() => setInputValue('notitie ')}
|
||||
className="px-2 py-0.5 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
|
||||
>
|
||||
notitie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInputValue('zoek ')}
|
||||
className="px-2 py-0.5 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
|
||||
>
|
||||
zoek
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user