| Epic | Status | Wat is gedaan |

|---------------------|---------------|---------------------|
  | E0: Foundation      |  Afgerond    | Types + DB migratie |
  | E1: Leefgebieden    |  Afgerond    | 3 componenten       |
  | E2: AI Generatie    |  Nog te doen | -                   |
  | E3: Behandelplan UI |  Nog te doen | -                   |

  Gemaakte bestanden:
  - lib/types/leefgebieden.ts - 7 domeinen met kleuren/emoji's
  - lib/types/behandelplan.ts - SMART doelen, interventies, Zod schemas
  - components/behandelplan/leefgebieden-form.tsx - Intake formulier
  - components/behandelplan/leefgebieden-scores.tsx - Score weergave
  - components/behandelplan/leefgebieden-badge.tsx - Domain badges
  - components/behandelplan/index.ts - Exports
This commit is contained in:
colinislit
2025-12-04 09:09:23 +01:00
parent 8e8a8e6f49
commit 621e1c90f9
22 changed files with 3369 additions and 12 deletions

View File

@@ -0,0 +1,14 @@
/**
* Behandelplan Components
*
* Export all behandelplan-related components
*/
// Leefgebieden (Life Domains)
export { LeefgebiedenBadge, LeefgebiedenBadgeGroup } from './leefgebieden-badge';
export {
LeefgebiedenScores,
LeefgebiedenScoresCard,
LeefgebiedenScoreBar,
} from './leefgebieden-scores';
export { LeefgebiedenForm, LeefgebiedenQuickForm } from './leefgebieden-form';

View File

@@ -0,0 +1,77 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { type LifeDomain, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
interface LeefgebiedenBadgeProps {
domain: LifeDomain;
showEmoji?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* Colored badge for a life domain
* Uses the domain's specific color from the meta definition
*/
export function LeefgebiedenBadge({
domain,
showEmoji = true,
size = 'md',
className,
}: LeefgebiedenBadgeProps) {
const meta = LIFE_DOMAIN_META[domain];
const sizeClasses = {
sm: 'text-xs px-1.5 py-0.5',
md: 'text-sm px-2 py-0.5',
lg: 'text-base px-3 py-1',
};
return (
<Badge
className={cn(
'font-medium border-0 text-white',
sizeClasses[size],
className
)}
style={{ backgroundColor: meta.color }}
>
{showEmoji && <span className="mr-1">{meta.emoji}</span>}
{meta.shortLabel}
</Badge>
);
}
interface LeefgebiedenBadgeGroupProps {
domains: LifeDomain[];
showEmoji?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* Group of life domain badges
*/
export function LeefgebiedenBadgeGroup({
domains,
showEmoji = true,
size = 'sm',
className,
}: LeefgebiedenBadgeGroupProps) {
if (domains.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-1', className)}>
{domains.map((domain) => (
<LeefgebiedenBadge
key={domain}
domain={domain}
showEmoji={showEmoji}
size={size}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,368 @@
'use client';
import { useState, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
type LifeDomainScore,
type LifeDomain,
type Priority,
LIFE_DOMAIN_META,
LIFE_DOMAINS,
createDefaultLifeDomainScores,
getScoreColor,
} from '@/lib/types/leefgebieden';
interface DomainFormRowProps {
score: LifeDomainScore;
onChange: (score: LifeDomainScore) => void;
expanded?: boolean;
onToggleExpand?: () => void;
}
const SCORE_LABELS: Record<number, string> = {
1: 'Zeer laag',
2: 'Laag',
3: 'Gemiddeld',
4: 'Goed',
5: 'Uitstekend',
};
const PRIORITY_OPTIONS: { value: Priority; label: string; color: string }[] = [
{ value: 'laag', label: 'Laag', color: 'bg-gray-200 text-gray-700' },
{ value: 'middel', label: 'Middel', color: 'bg-blue-100 text-blue-700' },
{ value: 'hoog', label: 'Hoog', color: 'bg-orange-100 text-orange-700' },
];
/**
* Single domain row in the form
*/
function DomainFormRow({
score,
onChange,
expanded = false,
onToggleExpand,
}: DomainFormRowProps) {
const meta = LIFE_DOMAIN_META[score.domain];
const handleBaselineChange = (values: number[]) => {
onChange({ ...score, baseline: values[0], current: values[0] });
};
const handleTargetChange = (values: number[]) => {
onChange({ ...score, target: values[0] });
};
const handlePriorityChange = (priority: Priority) => {
onChange({ ...score, priority });
};
const handleNotesChange = (notes: string) => {
onChange({ ...score, notes });
};
return (
<div
className={cn(
'border rounded-lg p-4 transition-all',
expanded ? 'bg-muted/50' : 'hover:bg-muted/30',
score.priority === 'hoog' && 'border-orange-300'
)}
>
{/* Header Row */}
<div
className="flex items-center justify-between cursor-pointer"
onClick={onToggleExpand}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: `${meta.color}20` }}
>
{meta.emoji}
</div>
<div>
<h4 className="font-medium">{meta.label}</h4>
<p className="text-sm text-muted-foreground">{meta.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="flex items-center gap-2">
<span
className="text-lg font-bold"
style={{ color: getScoreColor(score.baseline) }}
>
{score.baseline}
</span>
<span className="text-muted-foreground"></span>
<span className="text-lg font-bold text-foreground">
{score.target}
</span>
</div>
<span className="text-xs text-muted-foreground">
{SCORE_LABELS[score.baseline]}
</span>
</div>
<svg
className={cn(
'w-5 h-5 text-muted-foreground transition-transform',
expanded && 'rotate-180'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{/* Expanded Content */}
{expanded && (
<div className="mt-4 space-y-4 pt-4 border-t">
{/* Baseline Score Slider */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Huidige score (baseline)</Label>
<span className="text-sm font-medium">{score.baseline}</span>
</div>
<Slider
value={[score.baseline]}
onValueChange={handleBaselineChange}
min={1}
max={5}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Zeer laag</span>
<span>Uitstekend</span>
</div>
</div>
{/* Target Score Slider */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Doelscore</Label>
<span className="text-sm font-medium">{score.target}</span>
</div>
<Slider
value={[score.target]}
onValueChange={handleTargetChange}
min={1}
max={5}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Zeer laag</span>
<span>Uitstekend</span>
</div>
</div>
{/* Priority Selection */}
<div className="space-y-2">
<Label>Prioriteit voor behandeling</Label>
<div className="flex gap-2">
{PRIORITY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-all',
score.priority === option.value
? option.color
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
onClick={() => handlePriorityChange(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
{/* Notes */}
<div className="space-y-2">
<Label>Toelichting (optioneel)</Label>
<Textarea
value={score.notes}
onChange={(e) => handleNotesChange(e.target.value)}
placeholder={`Opmerkingen over ${meta.shortLabel.toLowerCase()}...`}
className="resize-none"
rows={2}
/>
</div>
</div>
)}
</div>
);
}
interface LeefgebiedenFormProps {
initialScores?: LifeDomainScore[];
onSave: (scores: LifeDomainScore[]) => void;
onCancel?: () => void;
isSaving?: boolean;
}
/**
* Complete form for entering life domain scores during intake
*/
export function LeefgebiedenForm({
initialScores,
onSave,
onCancel,
isSaving = false,
}: LeefgebiedenFormProps) {
const [scores, setScores] = useState<LifeDomainScore[]>(
initialScores || createDefaultLifeDomainScores()
);
const [expandedDomain, setExpandedDomain] = useState<LifeDomain | null>(null);
const handleScoreChange = useCallback((updatedScore: LifeDomainScore) => {
setScores((prev) =>
prev.map((s) => (s.domain === updatedScore.domain ? updatedScore : s))
);
}, []);
const handleToggleExpand = useCallback((domain: LifeDomain) => {
setExpandedDomain((prev) => (prev === domain ? null : domain));
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(scores);
};
const highPriorityCount = scores.filter((s) => s.priority === 'hoog').length;
return (
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>📊</span>
Leefgebieden Assessment
</CardTitle>
<CardDescription>
Beoordeel de 7 leefgebieden van de cliënt. Klik op een gebied om scores
en prioriteiten aan te passen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Summary Stats */}
<div className="flex items-center justify-between text-sm text-muted-foreground mb-4">
<span>
Klik op een leefgebied om de scores aan te passen
</span>
{highPriorityCount > 0 && (
<span className="text-orange-600 font-medium">
{highPriorityCount} hoge prioriteit{highPriorityCount > 1 ? 'en' : ''}
</span>
)}
</div>
{/* Domain Rows */}
<div className="space-y-2">
{LIFE_DOMAINS.map((domain) => {
const score = scores.find((s) => s.domain === domain)!;
return (
<DomainFormRow
key={domain}
score={score}
onChange={handleScoreChange}
expanded={expandedDomain === domain}
onToggleExpand={() => handleToggleExpand(domain)}
/>
);
})}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Annuleren
</Button>
)}
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Opslaan...' : 'Leefgebieden opslaan'}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}
interface LeefgebiedenQuickFormProps {
initialScores?: LifeDomainScore[];
onChange: (scores: LifeDomainScore[]) => void;
}
/**
* Compact version of the form for inline editing
*/
export function LeefgebiedenQuickForm({
initialScores,
onChange,
}: LeefgebiedenQuickFormProps) {
const [scores, setScores] = useState<LifeDomainScore[]>(
initialScores || createDefaultLifeDomainScores()
);
const handleScoreChange = useCallback(
(domain: LifeDomain, value: number) => {
const updated = scores.map((s) =>
s.domain === domain ? { ...s, baseline: value, current: value } : s
);
setScores(updated);
onChange(updated);
},
[scores, onChange]
);
return (
<div className="space-y-3">
{LIFE_DOMAINS.map((domain) => {
const score = scores.find((s) => s.domain === domain)!;
const meta = LIFE_DOMAIN_META[domain];
return (
<div key={domain} className="flex items-center gap-3">
<div className="w-8 text-center text-lg">{meta.emoji}</div>
<div className="flex-1">
<div className="flex justify-between text-sm mb-1">
<span>{meta.shortLabel}</span>
<span
className="font-medium"
style={{ color: getScoreColor(score.baseline) }}
>
{score.baseline}
</span>
</div>
<Slider
value={[score.baseline]}
onValueChange={(v) => handleScoreChange(domain, v[0])}
min={1}
max={5}
step={1}
className="w-full"
/>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
type LifeDomainScore,
type LifeDomain,
LIFE_DOMAIN_META,
LIFE_DOMAINS,
getScoreColor,
getAverageScore,
} from '@/lib/types/leefgebieden';
import { LeefgebiedenBadge } from './leefgebieden-badge';
interface LeefgebiedenScoreBarProps {
score: LifeDomainScore;
showTarget?: boolean;
compact?: boolean;
}
/**
* Single life domain score as a progress bar
*/
export function LeefgebiedenScoreBar({
score,
showTarget = true,
compact = false,
}: LeefgebiedenScoreBarProps) {
const meta = LIFE_DOMAIN_META[score.domain];
const percentage = (score.current / 5) * 100;
const targetPercentage = (score.target / 5) * 100;
return (
<div className={cn('space-y-1', compact ? 'py-1' : 'py-2')}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{meta.emoji}</span>
<span className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
{meta.shortLabel}
</span>
{score.priority === 'hoog' && (
<span className="text-xs text-orange-500 font-medium">
Prioriteit
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium" style={{ color: getScoreColor(score.current) }}>
{score.current}
</span>
{showTarget && (
<>
<span></span>
<span className="font-medium text-foreground">{score.target}</span>
</>
)}
</div>
</div>
<div className="relative">
{/* Custom progress bar with domain-specific color */}
<div className="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div
className="h-full transition-all duration-300"
style={{
width: `${percentage}%`,
backgroundColor: meta.color,
}}
/>
</div>
{showTarget && (
<div
className="absolute top-0 h-2 w-0.5 bg-foreground/50"
style={{ left: `${targetPercentage}%` }}
title={`Doel: ${score.target}`}
/>
)}
</div>
</div>
);
}
interface LeefgebiedenScoresProps {
scores: LifeDomainScore[];
showTarget?: boolean;
compact?: boolean;
showSummary?: boolean;
className?: string;
}
/**
* Display all 7 life domain scores
*/
export function LeefgebiedenScores({
scores,
showTarget = true,
compact = false,
showSummary = true,
className,
}: LeefgebiedenScoresProps) {
// Ensure we have all 7 domains in the correct order
const orderedScores = LIFE_DOMAINS.map((domain) => {
const found = scores.find((s) => s.domain === domain);
return (
found || {
domain,
baseline: 3,
current: 3,
target: 4,
notes: '',
priority: 'middel' as const,
}
);
});
const avgCurrent = getAverageScore(orderedScores, 'current');
const avgTarget = getAverageScore(orderedScores, 'target');
const highPriority = orderedScores.filter((s) => s.priority === 'hoog');
return (
<div className={cn('space-y-4', className)}>
{showSummary && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Gemiddelde score:{' '}
<span className="font-medium text-foreground">{avgCurrent}</span>
{showTarget && (
<span className="text-muted-foreground"> {avgTarget}</span>
)}
</span>
</div>
{highPriority.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Prioriteiten:</span>
{highPriority.map((s) => (
<LeefgebiedenBadge key={s.domain} domain={s.domain} size="sm" />
))}
</div>
)}
</div>
)}
<div className="space-y-1">
{orderedScores.map((score) => (
<LeefgebiedenScoreBar
key={score.domain}
score={score}
showTarget={showTarget}
compact={compact}
/>
))}
</div>
</div>
);
}
interface LeefgebiedenScoresCardProps {
scores: LifeDomainScore[];
title?: string;
showTarget?: boolean;
className?: string;
}
/**
* Life domain scores in a Card wrapper
*/
export function LeefgebiedenScoresCard({
scores,
title = 'Leefgebieden',
showTarget = true,
className,
}: LeefgebiedenScoresCardProps) {
return (
<Card className={className}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span>📊</span>
{title}
</CardTitle>
</CardHeader>
<CardContent>
<LeefgebiedenScores scores={scores} showTarget={showTarget} />
</CardContent>
</Card>
);
}