swift-cortex: technisch datamodel instroom/intake + ER-diagrammen #1
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Blog Overview Page
|
||||
*
|
||||
* Landing page with series cards and recent posts
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { getAllPosts, getSeriesWithCounts, type BlogSeries } from '@/lib/mdx/blog'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Blog - AI Speedrun',
|
||||
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
|
||||
openGraph: {
|
||||
title: 'Blog - AI Speedrun',
|
||||
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
|
||||
type: 'website',
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'Blog - AI Speedrun',
|
||||
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'}/blog`,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
const series = await getSeriesWithCounts()
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 md:mb-12 pt-20 md:pt-24">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
|
||||
Artikelen over AI-gestuurde ontwikkeling en de reis van het bouwen van een EPD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Series Section */}
|
||||
{series.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
|
||||
Series
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{series.map((serie) => (
|
||||
<SeriesCard key={serie.id} series={serie} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Posts Section */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
|
||||
Recente Posts
|
||||
</h2>
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-slate-500 text-lg text-center py-12">
|
||||
Nog geen blogposts gepubliceerd.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={`${post.seriesId}-${post.slug}`}
|
||||
href={`/blog/serie/${post.seriesId}/${post.slug}`}
|
||||
className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2 text-sm text-slate-500">
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
{post.seriesId.replace('-', ' ')}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span>•</span>
|
||||
<span>{post.readingTime} min leestijd</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 mb-2 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<p className="text-slate-600 leading-relaxed line-clamp-2">
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{post.frontmatter.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SeriesCard({ series }: { series: BlogSeries & { postCount: number } }) {
|
||||
const colorStyles = {
|
||||
teal: {
|
||||
bg: 'bg-teal-50',
|
||||
border: 'border-teal-200 hover:border-teal-400',
|
||||
badge: 'bg-teal-100 text-teal-700',
|
||||
dot: 'bg-teal-500',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200 hover:border-amber-400',
|
||||
badge: 'bg-amber-100 text-amber-700',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-200 hover:border-slate-400',
|
||||
badge: 'bg-slate-100 text-slate-700',
|
||||
dot: 'bg-slate-500',
|
||||
},
|
||||
}
|
||||
|
||||
const styles = colorStyles[series.color] || colorStyles.slate
|
||||
const statusLabels = {
|
||||
completed: 'Voltooid',
|
||||
active: 'Actief',
|
||||
planned: 'Gepland',
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/serie/${series.id}`}
|
||||
className={`block rounded-lg border-2 p-5 transition-all hover:shadow-md active:scale-[0.98] ${styles.bg} ${styles.border}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`w-2 h-2 rounded-full ${styles.dot}`} />
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{statusLabels[series.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-1">{series.title}</h3>
|
||||
<p className="text-sm text-slate-600 mb-3 line-clamp-2">{series.description}</p>
|
||||
<div className="text-xs text-slate-500">
|
||||
{series.postCount} {series.postCount === 1 ? 'deel' : 'delen'}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* Individual Blog Post Page
|
||||
*
|
||||
* Renders MDX content with series navigation
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc'
|
||||
import { getPost, getSeries, getPostsBySeries, getSeriesNavigation, getAllPosts } from '@/lib/mdx/blog'
|
||||
import { mdxComponents } from '../../../../documentatie/components/mdx-components'
|
||||
|
||||
interface PostPageProps {
|
||||
params: Promise<{ serieId: string; slug: string }>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPosts()
|
||||
return posts.map((post) => ({
|
||||
serieId: post.seriesId,
|
||||
slug: post.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PostPageProps) {
|
||||
const { serieId, slug } = await params
|
||||
const post = await getPost(serieId, slug)
|
||||
const series = await getSeries(serieId)
|
||||
|
||||
if (!post || !series) {
|
||||
return { title: 'Post niet gevonden' }
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
|
||||
// Use post-specific image if provided, otherwise fall back to default
|
||||
const ogImageUrl = post.frontmatter.image
|
||||
? `${siteUrl}${post.frontmatter.image.startsWith('/') ? '' : '/'}${post.frontmatter.image}`
|
||||
: `${siteUrl}/og-blog-default.png`
|
||||
|
||||
return {
|
||||
title: `${post.frontmatter.title} - ${series.title}`,
|
||||
description: post.frontmatter.description,
|
||||
authors: [{ name: 'Colin van der Heijden', url: 'https://ikbenlit.nl' }],
|
||||
keywords: post.frontmatter.tags || [],
|
||||
openGraph: {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['Colin van der Heijden'],
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: post.frontmatter.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: postUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: PostPageProps) {
|
||||
const { serieId, slug } = await params
|
||||
const post = await getPost(serieId, slug)
|
||||
const series = await getSeries(serieId)
|
||||
|
||||
if (!post || !series) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const navigation = await getSeriesNavigation(serieId, slug)
|
||||
const { frontmatter, content, readingTime } = post
|
||||
|
||||
// Generate Article structured data for SEO
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
|
||||
// Use post-specific image if provided, otherwise fall back to default
|
||||
const articleImage = frontmatter.image
|
||||
? `${siteUrl}${frontmatter.image.startsWith('/') ? '' : '/'}${frontmatter.image}`
|
||||
: `${siteUrl}/og-blog-default.png`
|
||||
const articleSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: frontmatter.title,
|
||||
description: frontmatter.description,
|
||||
image: articleImage,
|
||||
datePublished: frontmatter.date,
|
||||
dateModified: frontmatter.date,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: 'Colin van der Heijden',
|
||||
url: 'https://ikbenlit.nl',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'AI Speedrun',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/images/aispeedrun-logo.webp`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': postUrl,
|
||||
},
|
||||
articleSection: series.title,
|
||||
keywords: frontmatter.tags?.join(', ') || '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
timeRequired: `PT${readingTime}M`,
|
||||
}
|
||||
|
||||
// Generate BreadcrumbList structured data
|
||||
const breadcrumbSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: siteUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Blog',
|
||||
item: `${siteUrl}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: series.title,
|
||||
item: `${siteUrl}/blog/serie/${serieId}`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 4,
|
||||
name: frontmatter.title,
|
||||
item: postUrl,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const colorStyles = {
|
||||
teal: {
|
||||
badge: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
link: 'text-teal-600 hover:text-teal-700',
|
||||
},
|
||||
amber: {
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
link: 'text-amber-600 hover:text-amber-700',
|
||||
},
|
||||
slate: {
|
||||
badge: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
link: 'text-slate-600 hover:text-slate-700',
|
||||
},
|
||||
}
|
||||
|
||||
const styles = colorStyles[series.color] || colorStyles.slate
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-16">
|
||||
{/* Structured Data for SEO */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
|
||||
<article className="max-w-3xl mx-auto px-4 md:px-8">
|
||||
{/* Back link */}
|
||||
<div className="pt-20 md:pt-24 mb-6">
|
||||
<Link
|
||||
href={`/blog/serie/${serieId}`}
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Terug naar {series.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-8 pb-8 border-b border-slate-200">
|
||||
{/* Series badge */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<Link
|
||||
href={`/blog/serie/${serieId}`}
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border transition-colors hover:opacity-80 ${styles.badge}`}
|
||||
>
|
||||
{series.title}
|
||||
</Link>
|
||||
<span className="text-sm text-slate-500">
|
||||
Deel {navigation.current} van {navigation.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-4 leading-tight">
|
||||
{frontmatter.title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xl text-slate-600 leading-relaxed mb-4">
|
||||
{frontmatter.description}
|
||||
</p>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||
<time dateTime={frontmatter.date}>
|
||||
{new Date(frontmatter.date).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span>•</span>
|
||||
<span>{readingTime} min leestijd</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{frontmatter.tags && frontmatter.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{frontmatter.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2.5 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* MDX Content */}
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<MDXRemote source={content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
{/* Series Navigation */}
|
||||
<nav className="mt-12 pt-8 border-t border-slate-200">
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
|
||||
{series.title} Serie
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Previous */}
|
||||
<div>
|
||||
{navigation.previous ? (
|
||||
<Link
|
||||
href={`/blog/serie/${serieId}/${navigation.previous.slug}`}
|
||||
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 mb-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Vorige</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
|
||||
{navigation.previous.title}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next */}
|
||||
<div>
|
||||
{navigation.next ? (
|
||||
<Link
|
||||
href={`/blog/serie/${serieId}/${navigation.next.slug}`}
|
||||
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group text-right"
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-slate-500 mb-1">
|
||||
<span>Volgende</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
|
||||
{navigation.next.title}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Back to overview */}
|
||||
<footer className="mt-8 text-center">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-sm text-slate-600 hover:text-teal-600 transition-colors"
|
||||
>
|
||||
← Terug naar blog overzicht
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Series Overview Page
|
||||
*
|
||||
* Shows all posts in a specific series
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getSeries, getPostsBySeries, getAllSeries } from '@/lib/mdx/blog'
|
||||
|
||||
interface SeriesPageProps {
|
||||
params: Promise<{ serieId: string }>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const series = await getAllSeries()
|
||||
return series.map((serie) => ({ serieId: serie.id }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SeriesPageProps) {
|
||||
const { serieId } = await params
|
||||
const series = await getSeries(serieId)
|
||||
|
||||
if (!series) {
|
||||
return { title: 'Serie niet gevonden' }
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
const seriesUrl = `${siteUrl}/blog/serie/${serieId}`
|
||||
|
||||
return {
|
||||
title: `${series.title} - Blog | AI Speedrun`,
|
||||
description: series.description,
|
||||
openGraph: {
|
||||
title: series.title,
|
||||
description: series.description,
|
||||
type: 'website',
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: series.title,
|
||||
description: series.description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: seriesUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SeriesPage({ params }: SeriesPageProps) {
|
||||
const { serieId } = await params
|
||||
const series = await getSeries(serieId)
|
||||
|
||||
if (!series) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const posts = await getPostsBySeries(serieId)
|
||||
|
||||
const colorStyles = {
|
||||
teal: {
|
||||
bg: 'bg-teal-50',
|
||||
badge: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
dot: 'bg-teal-500',
|
||||
number: 'bg-teal-600 text-white',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50',
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
number: 'bg-amber-600 text-white',
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-slate-50',
|
||||
badge: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
dot: 'bg-slate-500',
|
||||
number: 'bg-slate-600 text-white',
|
||||
},
|
||||
}
|
||||
|
||||
const styles = colorStyles[series.color] || colorStyles.slate
|
||||
const statusLabels = {
|
||||
completed: 'Voltooid',
|
||||
active: 'Actief',
|
||||
planned: 'Gepland',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-16">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Back link */}
|
||||
<div className="pt-20 md:pt-24 mb-6">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Terug naar blog</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Series Header */}
|
||||
<header className={`rounded-xl p-6 md:p-8 mb-8 ${styles.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${styles.dot}`} />
|
||||
<span className={`text-xs font-medium px-2.5 py-1 rounded-full border ${styles.badge}`}>
|
||||
{statusLabels[series.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-3">
|
||||
{series.title}
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 leading-relaxed">
|
||||
{series.description}
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-slate-500">
|
||||
{posts.length} {posts.length === 1 ? 'deel' : 'delen'}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Posts List */}
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-12">
|
||||
Nog geen posts in deze serie.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{posts.map((post, index) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/serie/${serieId}/${post.slug}`}
|
||||
className="flex items-start gap-4 bg-white rounded-lg border border-slate-200 p-4 md:p-5 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${styles.number}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-1 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 mb-2 line-clamp-2">
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span>•</span>
|
||||
<span>{post.readingTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-slate-400 flex-shrink-0 mt-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Auth Code Handler
|
||||
*
|
||||
* Handles auth codes that Supabase redirects to the home page
|
||||
* Redirects to /auth/callback to process the code exchange
|
||||
*/
|
||||
export function AuthCodeHandler() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const code = searchParams.get('code')
|
||||
const type = searchParams.get('type')
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
// Build callback URL with all parameters
|
||||
const callbackUrl = new URL('/auth/callback', window.location.origin)
|
||||
callbackUrl.searchParams.set('code', code)
|
||||
if (type) {
|
||||
callbackUrl.searchParams.set('type', type)
|
||||
}
|
||||
// If it's a recovery flow, ensure we redirect to update-password
|
||||
if (type === 'recovery') {
|
||||
callbackUrl.searchParams.set('next', '/update-password')
|
||||
}
|
||||
|
||||
// Redirect to callback route
|
||||
router.replace(callbackUrl.pathname + callbackUrl.search)
|
||||
}
|
||||
}, [code, type, router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMotionValueEvent, useScroll, useTransform, motion } from "framer-motion";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Rocket, Database, Sparkles, Palette, Calendar, Layout,
|
||||
Users, Smartphone, FileText, Brain, Tags, Target,
|
||||
HelpCircle, Zap, Eye
|
||||
} from "lucide-react";
|
||||
|
||||
// Icon mapping
|
||||
const iconMap = {
|
||||
Rocket,
|
||||
Database,
|
||||
Sparkles,
|
||||
Palette,
|
||||
Calendar,
|
||||
Layout,
|
||||
Users,
|
||||
Smartphone,
|
||||
FileText,
|
||||
Brain,
|
||||
Tags,
|
||||
Target,
|
||||
HelpCircle,
|
||||
Zap,
|
||||
Eye,
|
||||
};
|
||||
|
||||
interface Feature {
|
||||
title: string;
|
||||
description: string;
|
||||
time?: string;
|
||||
traditional?: string;
|
||||
icon: keyof typeof iconMap;
|
||||
}
|
||||
|
||||
interface WeekData {
|
||||
weekNumber: number;
|
||||
title: string;
|
||||
status: "completed" | "in_progress" | "planned";
|
||||
description: string;
|
||||
features: Feature[];
|
||||
metrics: {
|
||||
hours: string;
|
||||
cost: string;
|
||||
linesOfCode: string;
|
||||
};
|
||||
achievements: string[];
|
||||
}
|
||||
|
||||
interface TimelineData {
|
||||
heading: string;
|
||||
description: string;
|
||||
weeks: WeekData[];
|
||||
}
|
||||
|
||||
interface BuildTimelineProps {
|
||||
data: TimelineData;
|
||||
}
|
||||
|
||||
const StatusBadge = ({ status }: { status: WeekData["status"] }) => {
|
||||
const styles = {
|
||||
completed: "bg-teal-100 text-teal-700 border-teal-200",
|
||||
in_progress: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
planned: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
completed: "Voltooid",
|
||||
in_progress: "Bezig",
|
||||
planned: "Gepland",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureCard = ({ feature }: { feature: Feature }) => {
|
||||
const Icon = iconMap[feature.icon];
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-teal-300 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-teal-100 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-slate-900 text-sm mb-1">
|
||||
{feature.title}
|
||||
</h4>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
{feature.time && feature.traditional && (
|
||||
<div className="mt-2 flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-teal-600">Met AI:</span>
|
||||
<span className="text-slate-900 font-semibold">{feature.time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-500">Traditioneel:</span>
|
||||
<span className="text-slate-600 line-through">{feature.traditional}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BuildTimeline = ({ data }: BuildTimelineProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setHeight(rect.height);
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start 10%", "end 50%"],
|
||||
});
|
||||
|
||||
const heightTransform = useTransform(scrollYProgress, [0, 1], [0, height]);
|
||||
const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-50 font-sans" ref={containerRef}>
|
||||
{/* Header */}
|
||||
<div className="max-w-7xl mx-auto py-16 px-4 md:px-8 lg:px-10 text-center">
|
||||
<h2 className="font-serif text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
{data.heading}
|
||||
</h2>
|
||||
<p className="text-slate-600 text-lg max-w-3xl mx-auto">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div ref={ref} className="relative max-w-7xl mx-auto pb-20">
|
||||
{data.weeks.map((week, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-start pt-10 md:pt-20 md:gap-10"
|
||||
>
|
||||
{/* Left side - Week title (sticky) */}
|
||||
<div className="sticky flex flex-col md:flex-row z-40 items-center top-40 self-start max-w-xs lg:max-w-sm md:w-full">
|
||||
{/* Timeline dot */}
|
||||
<div className="h-10 absolute left-3 md:left-3 w-10 rounded-full bg-slate-50 flex items-center justify-center">
|
||||
<div className="h-4 w-4 rounded-full bg-teal-500 border-2 border-white shadow-md" />
|
||||
</div>
|
||||
|
||||
{/* Week title - hidden on mobile */}
|
||||
<div className="hidden md:block md:pl-20">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-800 mb-2">
|
||||
{week.title}
|
||||
</h3>
|
||||
<StatusBadge status={week.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Content */}
|
||||
<div className="relative pl-20 pr-4 md:pl-4 w-full">
|
||||
{/* Week title - mobile only */}
|
||||
<div className="md:hidden mb-4">
|
||||
<h3 className="text-2xl font-bold text-slate-800 mb-2">
|
||||
{week.title}
|
||||
</h3>
|
||||
<StatusBadge status={week.status} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-slate-700 text-base leading-relaxed mb-6">
|
||||
{week.description}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
{week.features.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
|
||||
Features
|
||||
</h4>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{week.features.map((feature, fIndex) => (
|
||||
<FeatureCard key={fIndex} feature={feature} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
|
||||
Metrics
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-teal-600">
|
||||
{week.metrics.hours}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">Development</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-teal-600">
|
||||
{week.metrics.cost}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">Infrastructure</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-teal-600">
|
||||
{week.metrics.linesOfCode}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">Lines of Code</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
{week.achievements.length > 0 && (
|
||||
<div className="bg-teal-50 border border-teal-100 rounded-lg p-4 mb-4">
|
||||
<h4 className="text-sm font-semibold text-teal-700 uppercase tracking-wide mb-2">
|
||||
Achievements
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{week.achievements.map((achievement, aIndex) => (
|
||||
<li key={aIndex} className="text-sm text-slate-700">
|
||||
{achievement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to detailed documentation */}
|
||||
<a
|
||||
href="/documentatie"
|
||||
className="inline-flex items-center gap-2 text-teal-600 hover:text-teal-700 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span>Bekijk uitgebreide documentatie</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Animated timeline line */}
|
||||
<div
|
||||
style={{ height: height + "px" }}
|
||||
className="absolute md:left-8 left-8 top-0 overflow-hidden w-[2px] bg-gradient-to-b from-transparent via-slate-300 to-transparent"
|
||||
>
|
||||
<motion.div
|
||||
style={{
|
||||
height: heightTransform,
|
||||
opacity: opacityTransform,
|
||||
}}
|
||||
className="absolute inset-x-0 top-0 w-[2px] bg-gradient-to-b from-teal-500 via-teal-400 to-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Hero Quote Component
|
||||
*
|
||||
* Full-viewport hero section with Jensen Huang quote.
|
||||
* Displays quote, attribution, and subtitle from content.
|
||||
*/
|
||||
|
||||
import type { HeroContent } from '@/content/schemas/manifesto'
|
||||
import { MarketingShader } from './marketing-shader'
|
||||
|
||||
interface HeroQuoteProps {
|
||||
content: HeroContent
|
||||
}
|
||||
|
||||
export function HeroQuote({ content }: HeroQuoteProps) {
|
||||
return (
|
||||
<section
|
||||
className="relative min-h-screen flex items-center justify-center bg-slate-900"
|
||||
style={{ backgroundColor: '#0F172A' }} // Expliciet donkere achtergrond voor maximum contrast
|
||||
aria-label="Hero quote sectie"
|
||||
>
|
||||
{/* Shader Background - zeer subtiel (opacity 0.02) */}
|
||||
{/* Op mobile: donkere fallback, op desktop: subtiele shader */}
|
||||
<MarketingShader variant="hero" className="z-0" />
|
||||
|
||||
{/* Content Overlay */}
|
||||
<div className="relative z-10 w-full max-w-4xl px-4 md:px-8 text-center pt-20 md:pt-0">
|
||||
<blockquote
|
||||
className="text-white font-serif italic mb-4 md:mb-6"
|
||||
style={{
|
||||
fontSize: 'clamp(1.5rem, 5vw, 4rem)', // Mobile: smaller, Desktop: larger
|
||||
lineHeight: 'var(--line-height-tight)',
|
||||
textShadow: '0 2px 8px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.3)', // Sterkere shadow voor beter contrast
|
||||
color: '#FFFFFF', // Expliciet wit voor maximum contrast
|
||||
}}
|
||||
cite={content.attribution}
|
||||
>
|
||||
“{content.quote}”
|
||||
</blockquote>
|
||||
|
||||
<div className="text-white font-sans text-sm md:text-base" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)' }}>
|
||||
<cite className="not-italic font-semibold text-base md:text-lg text-white">
|
||||
{content.attribution}
|
||||
</cite>
|
||||
{content.attributionContext && (
|
||||
<span className="text-slate-200 text-sm md:text-base ml-2" aria-hidden="true">
|
||||
{content.attributionContext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-white mt-4 md:mt-6 text-base md:text-xl font-sans" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)' }}>
|
||||
{content.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { HeroSection } from '@/components/ui/hero-section-2'
|
||||
|
||||
interface HeroSectionClientProps {
|
||||
logo?: {
|
||||
url: string
|
||||
alt: string
|
||||
text?: string
|
||||
}
|
||||
slogan?: string
|
||||
title: React.ReactNode
|
||||
subtitle: string | React.ReactNode
|
||||
punchline?: string | React.ReactNode
|
||||
callToAction: {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
secondaryCallToAction?: {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
backgroundImage: string
|
||||
contactInfo?: {
|
||||
website: string
|
||||
phone: string
|
||||
address: string
|
||||
}
|
||||
}
|
||||
|
||||
export function HeroSectionClient(props: HeroSectionClientProps) {
|
||||
return <HeroSection {...props} />
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Insight Box Component
|
||||
*
|
||||
* Visual anchor for key takeaways with yellow left border.
|
||||
* Used to highlight important insights in the manifesto content.
|
||||
*/
|
||||
|
||||
interface InsightBoxProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'highlight'
|
||||
}
|
||||
|
||||
export function InsightBox({ children, variant = 'default' }: InsightBoxProps) {
|
||||
const borderColor = variant === 'highlight' ? '#D97706' : '#F59E0B'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-sm my-6 md:my-8 p-6 border-t-4 md:border-t-0 md:border-l-4"
|
||||
style={{
|
||||
borderLeftColor: borderColor,
|
||||
borderTopColor: borderColor,
|
||||
borderLeftWidth: '3px',
|
||||
borderTopWidth: '3px',
|
||||
}}
|
||||
>
|
||||
<p className="text-base md:text-lg font-serif text-slate-900 leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Marketing Shader Component
|
||||
*
|
||||
* Wrapper around DotScreenShader for marketing pages.
|
||||
* Very subtle opacity (0.02) for hero sections.
|
||||
* Includes mobile fallback and reduced motion support.
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Lazy load the shader component (heavy, only load when needed)
|
||||
const DotScreenShader = dynamic(
|
||||
() => import('@/components/ui/dot-shader-background').then((mod) => ({ default: mod.DotScreenShader })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null
|
||||
}
|
||||
)
|
||||
|
||||
interface MarketingShaderProps {
|
||||
variant?: 'hero' | 'section'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarketingShader({ variant = 'hero', className = '' }: MarketingShaderProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Check for reduced motion preference
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReducedMotion(mediaQuery.matches)
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
// Check for mobile (simple check)
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fallback voor mobile of reduced motion
|
||||
// Voor hero variant: donkere achtergrond, voor section variant: lichte achtergrond
|
||||
if (!mounted || reducedMotion || isMobile) {
|
||||
const fallbackBg = variant === 'hero'
|
||||
? 'bg-slate-900' // Donkere achtergrond voor hero
|
||||
: 'bg-gradient-to-br from-slate-50 to-slate-100' // Lichte achtergrond voor sections
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 ${fallbackBg} ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: shader component met zeer subtiele opacity
|
||||
// Opacity 0.02 is zeer subtiel - bijna onzichtbaar maar aanwezig
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-0 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
style={{ opacity: variant === 'hero' ? 0.02 : 0.03 }}
|
||||
>
|
||||
<DotScreenShader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Minimal Navigation Component
|
||||
*
|
||||
* Fixed top navigation with logo and links.
|
||||
* Mobile-friendly with hamburger menu.
|
||||
* Adaptive styling based on scroll position (hero vs content).
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import type { NavigationContent } from '@/content/schemas/manifesto'
|
||||
|
||||
interface MinimalNavProps {
|
||||
content: NavigationContent
|
||||
}
|
||||
|
||||
export function MinimalNav({ content }: MinimalNavProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Check if scrolled past hero section (approximately 100vh)
|
||||
setIsScrolled(window.scrollY > window.innerHeight * 0.8)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll() // Initial check
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Close menu when clicking outside or on link
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
const handleClickOutside = () => setIsMenuOpen(false)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [isMenuOpen])
|
||||
|
||||
// Determine nav styling based on scroll position
|
||||
// On hero: dark nav with light text, on content: light nav with dark text
|
||||
const navClasses = isScrolled
|
||||
? 'bg-white/95 backdrop-blur-sm shadow-sm'
|
||||
: 'bg-slate-900/80 backdrop-blur-sm md:bg-white/80 md:backdrop-blur-sm'
|
||||
|
||||
const logoClasses = isScrolled
|
||||
? 'text-teal-600'
|
||||
: 'text-white md:text-teal-600'
|
||||
|
||||
const linkClasses = isScrolled
|
||||
? 'text-slate-900 hover:text-slate-700'
|
||||
: 'text-white hover:text-slate-200 md:mix-blend-difference md:text-white md:hover:opacity-80'
|
||||
|
||||
const menuButtonClasses = isScrolled
|
||||
? 'text-slate-900 focus-visible:outline-slate-900'
|
||||
: 'text-white focus-visible:outline-white'
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-[60] transition-colors duration-200 ${navClasses}`}
|
||||
role="navigation"
|
||||
aria-label="Hoofdnavigatie"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`flex items-center gap-2 font-black text-xl uppercase font-sans tracking-tight leading-none transition-colors hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${logoClasses}`}
|
||||
aria-label="AI Speedrun Home"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{content.logo}
|
||||
<Image
|
||||
src="/images/aispeedrun-logo.webp"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation Links */}
|
||||
<div className="hidden md:flex gap-6 items-center" role="list">
|
||||
{content.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`font-semibold text-base font-sans transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${linkClasses}`}
|
||||
role="listitem"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className={`md:hidden p-2 -mr-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${menuButtonClasses}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}}
|
||||
aria-label="Menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className={`md:hidden border-t ${
|
||||
isScrolled
|
||||
? 'bg-white border-slate-200'
|
||||
: 'bg-slate-900 border-slate-800'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{content.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`block font-semibold text-base py-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${
|
||||
isScrolled
|
||||
? 'text-slate-900 hover:text-slate-700 focus-visible:outline-slate-900'
|
||||
: 'text-white hover:text-slate-200 focus-visible:outline-white'
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Reading Progress Bar Component
|
||||
*
|
||||
* Fixed top progress indicator that shows scroll progress through the page.
|
||||
* Smooth animation, 2px height, accent blue color.
|
||||
* Respects prefers-reduced-motion for accessibility.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check for reduced motion preference
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReducedMotion(mediaQuery.matches)
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
const updateProgress = () => {
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
||||
|
||||
// Calculate scroll progress percentage
|
||||
const scrollableHeight = documentHeight - windowHeight
|
||||
const currentProgress = scrollableHeight > 0
|
||||
? (scrollTop / scrollableHeight) * 100
|
||||
: 0
|
||||
|
||||
setProgress(Math.min(100, Math.max(0, currentProgress)))
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
updateProgress()
|
||||
|
||||
// Update on scroll
|
||||
window.addEventListener('scroll', updateProgress, { passive: true })
|
||||
window.addEventListener('resize', updateProgress, { passive: true })
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
window.removeEventListener('scroll', updateProgress)
|
||||
window.removeEventListener('resize', updateProgress)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-[64px] left-0 right-0 z-50 h-0.5 bg-slate-200"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Leesvoortgang"
|
||||
>
|
||||
<div
|
||||
className={`h-full bg-teal-600 ${reducedMotion ? '' : 'transition-all duration-150 ease-out'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Statement Section Component
|
||||
*
|
||||
* Full-width section with dark background for impactful statements.
|
||||
* Used to highlight key messages in the manifesto.
|
||||
*/
|
||||
|
||||
import type { StatementSection as StatementSectionType } from '@/content/schemas/manifesto'
|
||||
|
||||
interface StatementSectionProps {
|
||||
section: StatementSectionType
|
||||
}
|
||||
|
||||
export function StatementSection({ section }: StatementSectionProps) {
|
||||
const isDark = section.variant === 'dark' || !section.variant
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`w-full ${
|
||||
isDark ? 'bg-slate-900' : 'bg-slate-50'
|
||||
}`}
|
||||
style={{
|
||||
padding: '4rem 2rem',
|
||||
}}
|
||||
aria-labelledby={`statement-${section.id}`}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2
|
||||
id={`statement-${section.id}`}
|
||||
className={`font-bold mb-4 md:mb-6 font-serif ${
|
||||
isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}
|
||||
style={{
|
||||
fontSize: 'clamp(2rem, 5vw, 4rem)',
|
||||
lineHeight: 'var(--line-height-tight)',
|
||||
}}
|
||||
>
|
||||
{section.heading}
|
||||
</h2>
|
||||
<p
|
||||
className={`text-lg md:text-xl font-serif leading-relaxed ${
|
||||
isDark ? 'text-slate-300' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{section.content}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Send, CheckCircle, XCircle } from 'lucide-react'
|
||||
import type { FormSection } from '@/content/schemas/manifesto'
|
||||
import type { LeadFormData } from '@/app/api/leads/route'
|
||||
|
||||
interface ContactFormProps {
|
||||
content: FormSection
|
||||
}
|
||||
|
||||
type FormStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export function ContactForm({ content }: ContactFormProps) {
|
||||
const [status, setStatus] = useState<FormStatus>('idle')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [formData, setFormData] = useState<LeadFormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
projectType: '',
|
||||
budget: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const validateField = (name: keyof LeadFormData, value: string): string | null => {
|
||||
const field = content.fields[name]
|
||||
|
||||
if (field.required && !value.trim()) {
|
||||
return field.error || `${field.label} is verplicht`
|
||||
}
|
||||
|
||||
if (name === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
return 'Ongeldig email adres'
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'message' && value && field.minLength) {
|
||||
if (value.length < field.minLength) {
|
||||
return `Minimaal ${field.minLength} karakters vereist`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
|
||||
// Clear error on change
|
||||
if (errors[name]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[name]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (
|
||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
const error = validateField(name as keyof LeadFormData, value)
|
||||
|
||||
if (error) {
|
||||
setErrors(prev => ({ ...prev, [name]: error }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate all fields
|
||||
const newErrors: Record<string, string> = {}
|
||||
Object.keys(formData).forEach(key => {
|
||||
const error = validateField(key as keyof LeadFormData, formData[key as keyof LeadFormData] || '')
|
||||
if (error) {
|
||||
newErrors[key] = error
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
// Submit form
|
||||
setStatus('submitting')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/leads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Er ging iets mis')
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
projectType: '',
|
||||
budget: '',
|
||||
message: '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
{content.success.title}
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
{content.success.message}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{content.success.cta}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||
<XCircle className="w-16 h-16 text-red-600 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
{content.error.title}
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
{content.error.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="inline-block px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{content.error.retry}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.name.label} {content.fields.name.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.name.placeholder}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.name ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.email.label} {content.fields.email.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.email.placeholder}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.email ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.company.label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
placeholder={content.fields.company.placeholder}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Type */}
|
||||
<div>
|
||||
<label htmlFor="projectType" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.projectType.label} {content.fields.projectType.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id="projectType"
|
||||
name="projectType"
|
||||
value={formData.projectType}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.projectType ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
<option value="">{content.fields.projectType.placeholder}</option>
|
||||
{content.fields.projectType.options?.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.projectType && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.projectType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div>
|
||||
<label htmlFor="budget" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.budget.label}
|
||||
</label>
|
||||
<select
|
||||
id="budget"
|
||||
name="budget"
|
||||
value={formData.budget}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
<option value="">{content.fields.budget.placeholder}</option>
|
||||
{content.fields.budget.options?.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.message.label} {content.fields.message.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.message.placeholder}
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.message ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{formData.message.length} / {content.fields.message.minLength} minimum
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full px-8 py-4 bg-green-600 hover:bg-green-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{content.buttons.submitting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
{content.buttons.submit}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Contact Page - Under Construction
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Construction } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
|
||||
return {
|
||||
title: 'Contact - AI Speedrun',
|
||||
description: 'Deze pagina is momenteel in ontwikkeling.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Contact - AI Speedrun',
|
||||
description: 'Deze pagina is momenteel in ontwikkeling',
|
||||
images: [`${siteUrl}/og-image.png`],
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/contact`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-white px-4">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-orange-100 rounded-full mb-8">
|
||||
<Construction className="w-12 h-12 text-orange-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
|
||||
Pagina in ontwikkeling
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-slate-600 mb-8">
|
||||
We zijn bezig met het bouwen van deze pagina. Kom binnenkort terug!
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Terug naar home
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Release Detail Page
|
||||
*
|
||||
* Renders individual release note from MDX file
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc'
|
||||
import { getRelease, getAllReleases } from '@/lib/mdx/documentatie'
|
||||
import { mdxComponents } from '../components/mdx-components'
|
||||
|
||||
interface ReleasePageProps {
|
||||
params: Promise<{
|
||||
category: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Generate static params for all releases
|
||||
export async function generateStaticParams() {
|
||||
const releases = await getAllReleases()
|
||||
|
||||
return releases.map((release) => ({
|
||||
category: release.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: ReleasePageProps) {
|
||||
const { category } = await params
|
||||
const release = await getRelease(category)
|
||||
|
||||
if (!release) {
|
||||
return {
|
||||
title: 'Release Not Found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${release.frontmatter.title} - Documentatie`,
|
||||
description: release.frontmatter.description,
|
||||
}
|
||||
}
|
||||
|
||||
function ArticleJsonLd({
|
||||
title,
|
||||
description,
|
||||
releaseDate,
|
||||
slug,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
releaseDate: string
|
||||
slug: string
|
||||
}) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Article',
|
||||
'@id': `${siteUrl}/documentatie/${slug}#article`,
|
||||
headline: title,
|
||||
description: description,
|
||||
datePublished: releaseDate,
|
||||
dateModified: releaseDate,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: 'Colin van der Heijden',
|
||||
url: 'https://ikbenlit.nl',
|
||||
},
|
||||
publisher: { '@id': `${siteUrl}/#organization` },
|
||||
mainEntityOfPage: `${siteUrl}/documentatie/${slug}`,
|
||||
inLanguage: 'nl-NL',
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${siteUrl}/documentatie/${slug}#breadcrumb`,
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: siteUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Documentatie',
|
||||
item: `${siteUrl}/documentatie`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ReleasePage({ params }: ReleasePageProps) {
|
||||
const { category } = await params
|
||||
const release = await getRelease(category)
|
||||
|
||||
if (!release) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { frontmatter, content } = release
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-16">
|
||||
<ArticleJsonLd
|
||||
title={frontmatter.title}
|
||||
description={frontmatter.description}
|
||||
releaseDate={frontmatter.releaseDate}
|
||||
slug={category}
|
||||
/>
|
||||
<article className="max-w-4xl mx-auto px-4 md:px-8 pt-20 md:pt-20">
|
||||
{/* Header */}
|
||||
<header className="mb-8 pb-8 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<StatusBadge status={frontmatter.status} />
|
||||
<span className="text-sm text-slate-500">v{frontmatter.version}</span>
|
||||
<span className="text-sm text-slate-500">•</span>
|
||||
<time className="text-sm text-slate-500">
|
||||
{new Date(frontmatter.releaseDate).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
|
||||
{frontmatter.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-slate-600">
|
||||
{frontmatter.description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* MDX Content */}
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<MDXRemote source={content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<footer className="mt-12 pt-8 border-slate-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<a
|
||||
href="/documentatie"
|
||||
className="text-teal-600 hover:text-teal-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Terug naar overzicht
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/#timeline"
|
||||
className="text-slate-600 hover:text-slate-700 font-medium"
|
||||
>
|
||||
Bekijk timeline →
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
|
||||
const styles = {
|
||||
completed: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
in_progress: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
planned: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
completed: 'Voltooid',
|
||||
in_progress: 'Bezig',
|
||||
planned: 'Gepland',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* Custom MDX Components
|
||||
*
|
||||
* Styled components for rendering MDX content
|
||||
*/
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
|
||||
/**
|
||||
* Generate slug from heading text for anchor links
|
||||
*/
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w\-]+/g, '')
|
||||
.replace(/\-\-+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from React children
|
||||
*/
|
||||
function getTextContent(children: React.ReactNode): string {
|
||||
if (typeof children === 'string') return children
|
||||
if (Array.isArray(children)) return children.map(getTextContent).join('')
|
||||
if (children && typeof children === 'object' && 'props' in children) {
|
||||
return getTextContent(children.props.children)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const mdxComponents: MDXComponents = {
|
||||
// Headings with anchor links
|
||||
h1: ({ children, ...props }) => {
|
||||
const text = getTextContent(children)
|
||||
const id = slugify(text)
|
||||
return (
|
||||
<h1 id={id} className="text-4xl font-bold text-slate-900 mt-8 mb-4 scroll-mt-20" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2: ({ children, ...props }) => {
|
||||
const text = getTextContent(children)
|
||||
const id = slugify(text)
|
||||
return (
|
||||
<h2 id={id} className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2 scroll-mt-20" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3: ({ children, ...props }) => {
|
||||
const text = getTextContent(children)
|
||||
const id = slugify(text)
|
||||
return (
|
||||
<h3 id={id} className="text-2xl font-semibold text-slate-900 mt-6 mb-3 scroll-mt-20" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4: ({ children, ...props }) => {
|
||||
const text = getTextContent(children)
|
||||
const id = slugify(text)
|
||||
return (
|
||||
<h4 id={id} className="text-xl font-semibold text-slate-900 mt-4 mb-2 scroll-mt-20" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
|
||||
// Paragraphs
|
||||
p: ({ children, ...props }) => (
|
||||
<div className="text-slate-700 leading-relaxed mb-4" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className="list-disc list-inside space-y-2 mb-4 text-slate-700" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="list-decimal list-inside space-y-2 mb-4 text-slate-700" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }) => (
|
||||
<li className="ml-4" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }) => (
|
||||
<Link
|
||||
href={href || '#'}
|
||||
className="text-teal-600 hover:text-teal-700 underline underline-offset-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, width, height, ...props }) => {
|
||||
if (!src) return null
|
||||
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<div className="relative rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
width={Number(width) || 1200}
|
||||
height={Number(height) || 675}
|
||||
className="w-full h-auto"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{alt && (
|
||||
<figcaption className="text-sm text-slate-500 italic mt-2 text-center">
|
||||
{alt}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
},
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre className="bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto mb-4 text-sm" {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, ...props }) => (
|
||||
<code className="bg-slate-100 text-slate-800 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
|
||||
// Blockquote
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote className="border-l-4 border-teal-500 pl-4 py-2 my-4 bg-teal-50 text-slate-700 italic" {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: (props) => (
|
||||
<hr className="my-8 border-slate-200" {...props} />
|
||||
),
|
||||
|
||||
// Table
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="overflow-x-auto my-6">
|
||||
<table className="min-w-full divide-y divide-slate-200" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th className="px-4 py-3 bg-slate-50 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td className="px-4 py-3 text-sm text-slate-700 border-t border-slate-200" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Strong/Bold
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="font-semibold text-slate-900" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
|
||||
// Emphasis/Italic
|
||||
em: ({ children, ...props }) => (
|
||||
<em className="italic" {...props}>
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Release Sidebar Wrapper
|
||||
*
|
||||
* Simple wrapper to avoid webpack module resolution issues
|
||||
*/
|
||||
|
||||
import { ReleaseSidebar } from './release-sidebar'
|
||||
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
|
||||
|
||||
interface ReleaseSidebarWrapperProps {
|
||||
releases: ReleaseNote[]
|
||||
metadata: {
|
||||
groups: GroupMetadata[]
|
||||
categories: CategoryMetadata[]
|
||||
}
|
||||
tocMap: Record<string, TocItem[]>
|
||||
}
|
||||
|
||||
export default function ReleaseSidebarWrapper(props: ReleaseSidebarWrapperProps) {
|
||||
return <ReleaseSidebar {...props} />
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Release Sidebar Navigation
|
||||
*
|
||||
* Fixed sidebar with grouped list of all releases
|
||||
* Auto-generated from MDX files
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
|
||||
|
||||
interface ReleaseSidebarProps {
|
||||
releases: ReleaseNote[]
|
||||
metadata: {
|
||||
groups: GroupMetadata[]
|
||||
categories: CategoryMetadata[]
|
||||
}
|
||||
tocMap: Record<string, TocItem[]>
|
||||
}
|
||||
|
||||
export function ReleaseSidebar({ releases, metadata, tocMap }: ReleaseSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
||||
foundation: true,
|
||||
architecture: true,
|
||||
features: true,
|
||||
infrastructure: true,
|
||||
bugs: true,
|
||||
})
|
||||
|
||||
// Auto-close sidebar on navigation
|
||||
useEffect(() => {
|
||||
setIsExpanded(false)
|
||||
}, [pathname])
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
setExpandedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}))
|
||||
}
|
||||
|
||||
// Group releases by their group
|
||||
const releasesByGroup = releases.reduce((acc, release) => {
|
||||
const group = release.frontmatter.group
|
||||
if (!acc[group]) acc[group] = []
|
||||
acc[group].push(release)
|
||||
return acc
|
||||
}, {} as Record<string, ReleaseNote[]>)
|
||||
|
||||
// Check if we're on a specific release page
|
||||
const isReleaseDetail = pathname.startsWith('/documentatie/') && pathname !== '/documentatie'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* MOBILE: Overlay when expanded */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/40 backdrop-blur-sm z-30 top-16"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MOBILE: Collapsible sidebar from left */}
|
||||
<aside
|
||||
className={`
|
||||
lg:hidden
|
||||
fixed top-16 bottom-0 left-0 z-40
|
||||
bg-white border-r border-slate-200
|
||||
transition-all duration-300 ease-in-out
|
||||
${isExpanded ? 'w-80 shadow-2xl' : 'w-12'}
|
||||
`}
|
||||
>
|
||||
{/* Collapsed state: Icon bar */}
|
||||
{!isExpanded && (
|
||||
<div className="flex flex-col items-center pt-6">
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="p-2 rounded-lg hover:bg-teal-50 transition-colors group"
|
||||
aria-label="Open documentatie menu"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-slate-600 group-hover:text-teal-600 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded state: Full navigation */}
|
||||
{isExpanded && (
|
||||
<div className="p-6 overflow-y-auto h-full">
|
||||
{/* Header met close button */}
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-bold text-slate-900">Documentatie</h2>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 transition-colors group"
|
||||
aria-label="Sluit menu"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{/* Overzicht link */}
|
||||
<Link
|
||||
href="/documentatie"
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === '/documentatie' && !isReleaseDetail
|
||||
? 'bg-teal-50 text-teal-700'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span>Overzicht</span>
|
||||
</Link>
|
||||
|
||||
{/* Grouped releases */}
|
||||
{metadata?.groups && metadata.groups
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((group) => {
|
||||
const groupReleases = releasesByGroup[group.id] || []
|
||||
const isGroupExpanded = expandedGroups[group.id]
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
{isGroupExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Group items */}
|
||||
{isGroupExpanded && (
|
||||
<div className="space-y-1 ml-2">
|
||||
{groupReleases.map((release) => {
|
||||
const isActive = pathname === `/documentatie/${release.slug}`
|
||||
const toc = tocMap[release.slug] || []
|
||||
|
||||
return (
|
||||
<div key={release.slug}>
|
||||
<Link
|
||||
href={`/documentatie/${release.slug}`}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
|
||||
isActive
|
||||
? 'bg-teal-50 text-teal-700'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={release.frontmatter.status} />
|
||||
<span className="whitespace-normal">{release.frontmatter.title}</span>
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||
}`} />
|
||||
</Link>
|
||||
|
||||
{/* Table of Contents for active page */}
|
||||
{isActive && toc.length > 0 && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
|
||||
{toc.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
|
||||
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* DESKTOP: Fixed sidebar */}
|
||||
<aside className="hidden lg:block w-80 fixed top-16 bottom-0 bg-white border-r border-slate-200 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
|
||||
Documentatie
|
||||
</h2>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{/* Overview link */}
|
||||
<Link
|
||||
href="/documentatie"
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${pathname === '/documentatie' && !isReleaseDetail
|
||||
? 'bg-teal-50 text-teal-700'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span>Overzicht</span>
|
||||
</Link>
|
||||
|
||||
{/* Grouped releases */}
|
||||
{metadata?.groups && metadata.groups
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((group) => {
|
||||
const groupReleases = releasesByGroup[group.id] || []
|
||||
const isExpanded = expandedGroups[group.id]
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Group items */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 ml-2">
|
||||
{groupReleases.map((release) => {
|
||||
const isActive = pathname === `/documentatie/${release.slug}`
|
||||
const toc = tocMap[release.slug] || []
|
||||
|
||||
return (
|
||||
<div key={release.slug}>
|
||||
<Link
|
||||
href={`/documentatie/${release.slug}`}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
|
||||
? 'bg-teal-50 text-teal-700'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={release.frontmatter.status} />
|
||||
<span className="whitespace-normal">{release.frontmatter.title}</span>
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
|
||||
}`} />
|
||||
</Link>
|
||||
|
||||
{/* Table of Contents for active page */}
|
||||
{isActive && toc.length > 0 && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
|
||||
{toc.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
|
||||
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
|
||||
const colors = {
|
||||
completed: 'bg-teal-500',
|
||||
in_progress: 'bg-amber-500',
|
||||
planned: 'bg-slate-300',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${colors[status]}`} />
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Releases Layout
|
||||
*
|
||||
* Layout for release notes pages with sidebar navigation
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { getAllReleases, getCategoryMetadata, extractHeadings, type ReleaseNote, type TocItem } from '@/lib/mdx/documentatie'
|
||||
import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
|
||||
|
||||
interface ReleasesLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
|
||||
let releases: ReleaseNote[] = []
|
||||
let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
|
||||
let tocMap: Record<string, TocItem[]> = {}
|
||||
|
||||
try {
|
||||
releases = await getAllReleases()
|
||||
metadata = await getCategoryMetadata()
|
||||
|
||||
// Extract headings for each release
|
||||
tocMap = releases.reduce((acc, release) => {
|
||||
acc[release.slug] = extractHeadings(release.content)
|
||||
return acc
|
||||
}, {} as Record<string, TocItem[]>)
|
||||
} catch (error) {
|
||||
console.error('Error loading releases or metadata:', error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Mobile sidebar is sticky, so we need padding for header only */}
|
||||
<div className="pt-16 lg:pt-0">
|
||||
<div className="max-w-[1600px] mx-auto">
|
||||
<div className="flex">
|
||||
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
|
||||
<ReleaseSidebarWrapper releases={releases} metadata={metadata} tocMap={tocMap} />
|
||||
|
||||
{/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
|
||||
<div className="flex-1 ml-12 lg:ml-80">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Releases Overview Page
|
||||
*
|
||||
* Landing page for all release notes grouped by category
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { getAllReleases } from '@/lib/mdx/documentatie'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documentatie - AI Speedrun',
|
||||
description: 'Feature documentatie van het Mini-ECD prototype. Transparant build-in-public overzicht van gebouwde functionaliteit.',
|
||||
}
|
||||
|
||||
export default async function ReleasesPage() {
|
||||
const releases = await getAllReleases()
|
||||
|
||||
// Group by status for better overview
|
||||
const completed = releases.filter(r => r.frontmatter.status === 'completed')
|
||||
const inProgress = releases.filter(r => r.frontmatter.status === 'in_progress')
|
||||
const planned = releases.filter(r => r.frontmatter.status === 'planned')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 pb-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 md:mb-12 pt-20 md:pt-20">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
|
||||
Feature Documentatie
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
|
||||
Uitgebreide documentatie van alle gebouwde functionaliteit. Build in public met transparantie over features, implementatie en kosten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-12">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
|
||||
<div className="text-4xl md:text-5xl font-bold text-teal-600 mb-2">{completed.length}</div>
|
||||
<div className="text-sm font-medium text-slate-600">Voltooid</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
|
||||
<div className="text-4xl md:text-5xl font-bold text-amber-600 mb-2">{inProgress.length}</div>
|
||||
<div className="text-sm font-medium text-slate-600">In Progress</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
|
||||
<div className="text-4xl md:text-5xl font-bold text-slate-400 mb-2">{planned.length}</div>
|
||||
<div className="text-sm font-medium text-slate-600">Gepland</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completed Releases */}
|
||||
{completed.length > 0 && (
|
||||
<section className="mb-10 md:mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
|
||||
✅ Voltooid
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{completed.map((release) => (
|
||||
<ReleaseCard key={release.slug} release={release} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* In Progress Releases */}
|
||||
{inProgress.length > 0 && (
|
||||
<section className="mb-10 md:mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
|
||||
🔄 In Progress
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{inProgress.map((release) => (
|
||||
<ReleaseCard key={release.slug} release={release} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Planned Releases */}
|
||||
{planned.length > 0 && (
|
||||
<section className="mb-10 md:mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
|
||||
⏳ Gepland
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{planned.map((release) => (
|
||||
<ReleaseCard key={release.slug} release={release} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseCard({ release }: { release: any }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/documentatie/${release.slug}`}
|
||||
className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<h3 className="text-lg md:text-xl font-bold text-slate-900 leading-tight">
|
||||
{release.frontmatter.title}
|
||||
</h3>
|
||||
<StatusBadge status={release.frontmatter.status} />
|
||||
</div>
|
||||
<p className="text-slate-600 mb-3 text-sm md:text-base leading-relaxed">
|
||||
{release.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-4 text-xs md:text-sm text-slate-500">
|
||||
<span className="capitalize">{release.frontmatter.group.replace('-', ' ')}</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>v{release.frontmatter.version}</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="whitespace-nowrap">{new Date(release.frontmatter.releaseDate).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-400 flex-shrink-0 self-start sm:self-center">
|
||||
<svg className="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
|
||||
const styles = {
|
||||
completed: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
in_progress: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
planned: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
completed: 'Voltooid',
|
||||
in_progress: 'Bezig',
|
||||
planned: 'Gepland',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Marketing Layout
|
||||
*
|
||||
* Full-width layout without sidebar for marketing pages.
|
||||
* Includes minimal navigation and reading progress bar.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - ReadingProgress is a client component, automatically code-split by Next.js
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { getContent } from '@/lib/content/loader'
|
||||
import type { NavigationContent } from '@/content/schemas/manifesto'
|
||||
import { MinimalNav } from './components/minimal-nav'
|
||||
import { ReadingProgress } from './components/reading-progress'
|
||||
|
||||
interface MarketingLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default async function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
// Load navigation content
|
||||
const navigationContent = await getContent<NavigationContent>('nl', 'navigation')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a href="#main-content" className="skip-to-main">
|
||||
Spring naar hoofdinhoud
|
||||
</a>
|
||||
|
||||
{/* Minimal Navigation - fixed top */}
|
||||
<MinimalNav content={navigationContent} />
|
||||
|
||||
{/* Reading progress bar - fixed top, below nav */}
|
||||
{/* Client component, automatically code-split by Next.js */}
|
||||
<ReadingProgress />
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Homepage - AI Speedrun v2.1
|
||||
*
|
||||
* Vereenvoudigde landing page met:
|
||||
* - Hero quote (Jensen Huang)
|
||||
* - Statement section (Software on Demand concept)
|
||||
* - Timeline (coming in E1.S3)
|
||||
* - CTA naar /login
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - Critical content (Hero, Statement) loads immediately
|
||||
* - Timeline will be lazy loaded when added
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Suspense } from 'react'
|
||||
import { getContent } from '@/lib/content/loader'
|
||||
import type { MetadataContent } from '@/content/schemas/manifesto'
|
||||
import { HeroSectionClient } from './components/hero-section-client'
|
||||
import { BuildTimeline } from './components/build-timeline'
|
||||
import { WhyMe } from '@/components/ui/why-me'
|
||||
import { AuthCodeHandler } from './components/auth-code-handler'
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const metadataContent = await getContent<MetadataContent>('nl', 'metadata')
|
||||
const meta = metadataContent.manifesto
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
const ogImageUrl = `${siteUrl}${meta.ogImage}`
|
||||
|
||||
return {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
authors: [{ name: 'Colin van der Heijden' }],
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: meta.ogTitle,
|
||||
description: meta.ogDescription,
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: meta.ogTitle,
|
||||
},
|
||||
],
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: meta.ogTitle,
|
||||
description: meta.ogDescription,
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: siteUrl,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Content interfaces
|
||||
interface HeroContent {
|
||||
title: {
|
||||
main: string
|
||||
accent: string
|
||||
}
|
||||
subtitle: string
|
||||
punchline: {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
callToAction: {
|
||||
primary: {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
secondary: {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
backgroundImage: string
|
||||
}
|
||||
|
||||
interface StatementContent {
|
||||
hero: HeroContent
|
||||
}
|
||||
|
||||
interface Feature {
|
||||
title: string
|
||||
description: string
|
||||
time?: string
|
||||
traditional?: string
|
||||
icon: "Rocket" | "Database" | "Sparkles" | "Palette" | "Calendar" | "Layout" | "Users" | "Smartphone" | "FileText" | "Brain" | "Tags" | "Target" | "HelpCircle" | "Zap" | "Eye"
|
||||
}
|
||||
|
||||
interface WeekData {
|
||||
weekNumber: number
|
||||
title: string
|
||||
status: "completed" | "in_progress" | "planned"
|
||||
description: string
|
||||
features: Feature[]
|
||||
metrics: {
|
||||
hours: string
|
||||
cost: string
|
||||
linesOfCode: string
|
||||
}
|
||||
achievements: string[]
|
||||
}
|
||||
|
||||
interface TimelineContent {
|
||||
heading: string
|
||||
description: string
|
||||
weeks: WeekData[]
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
// Load content
|
||||
const manifestoContent = await getContent<StatementContent>('nl', 'manifesto')
|
||||
const timelineContent = await getContent<TimelineContent>('nl', 'timeline')
|
||||
const heroContent = manifestoContent.hero
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Handle auth codes that Supabase redirects to home page */}
|
||||
<Suspense fallback={null}>
|
||||
<AuthCodeHandler />
|
||||
</Suspense>
|
||||
|
||||
{/* Hero Section */}
|
||||
<HeroSectionClient
|
||||
title={
|
||||
<>
|
||||
{heroContent.title.main}{' '}
|
||||
<span className="text-teal-600 block mt-2">
|
||||
{heroContent.title.accent}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
subtitle={heroContent.subtitle}
|
||||
punchline={
|
||||
<>
|
||||
{heroContent.punchline.question}
|
||||
<br />
|
||||
<br />
|
||||
<strong>{heroContent.punchline.answer}</strong>
|
||||
</>
|
||||
}
|
||||
callToAction={heroContent.callToAction.primary}
|
||||
secondaryCallToAction={heroContent.callToAction.secondary}
|
||||
backgroundImage={heroContent.backgroundImage}
|
||||
/>
|
||||
|
||||
{/* Statement Section - Software on Demand */}
|
||||
<section className="w-full md:max-w-[750px] mx-auto px-4 md:px-16 py-16 md:py-24">
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<h2 className="font-serif text-3xl md:text-4xl font-bold text-slate-900 mb-8">
|
||||
Software on Demand: Van €100k naar €200
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 text-lg leading-relaxed text-slate-700">
|
||||
<p>
|
||||
<strong className="text-slate-900">Het probleem:</strong> Enterprise software kost €100.000+ per jaar
|
||||
en implementaties duren 12-24 maanden. Je betaalt voor potentieel, niet voor werkelijke waarde.
|
||||
Vendors optimaliseren voor meer seats, meer modules, meer lock-in.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong className="text-slate-900">De oplossing:</strong> AI-powered development verkort dit naar
|
||||
4 weken en €200 totale kosten. Niet omdat AI magisch is, maar omdat je 80% van de standaard-code
|
||||
niet meer hoeft te schrijven. De kostenbasis verschuift compleet - geen armies van consultants,
|
||||
geen jarenlange developmenttrajecten.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong className="text-slate-900">Het bewijs:</strong> Dit EPD prototype is het levende bewijs.
|
||||
Gebouwd in 4 weken, volledig transparant, build in public. Infrastructuur die schaalt met gebruik,
|
||||
AI die de heavy lifting doet. En het belangrijkste: je bezit de code, je controleert de roadmap.
|
||||
</p>
|
||||
|
||||
<p className="text-teal-700 font-medium text-xl pt-4">
|
||||
Volg de voortgang hieronder en zie hoe elk onderdeel tot leven komt →
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline Section */}
|
||||
<section id="timeline">
|
||||
<BuildTimeline data={timelineContent} />
|
||||
</section>
|
||||
|
||||
{/* About Me Section */}
|
||||
<WhyMe />
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 px-4 text-center bg-gradient-to-br from-slate-50 to-white">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
Klaar om het prototype te proberen?
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 mb-8 max-w-2xl mx-auto">
|
||||
Login om toegang te krijgen tot de EPD applicatie en zie AI-gestuurde workflows in actie
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-block px-8 py-3 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Probeer het prototype
|
||||
</a>
|
||||
<a
|
||||
href="#timeline"
|
||||
className="inline-block px-8 py-3 bg-white hover:bg-slate-50 text-slate-700 font-medium rounded-lg border-2 border-slate-300 transition-colors"
|
||||
>
|
||||
Bekijk voortgang
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Behandelplan Generate API
|
||||
*
|
||||
* POST /api/behandelplan/generate
|
||||
* Genereert een behandelplan met Claude AI
|
||||
*/
|
||||
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
BEHANDELPLAN_SYSTEM_PROMPT,
|
||||
buildUserPrompt,
|
||||
validatePlanContext,
|
||||
type PlanContext,
|
||||
} from '@/lib/ai/behandelplan-prompt';
|
||||
import { type Severity } from '@/lib/ai/intervention-mapping';
|
||||
import {
|
||||
GeneratedPlanSchema,
|
||||
type GeneratedPlan,
|
||||
} from '@/lib/types/behandelplan';
|
||||
import { createDefaultLifeDomainScores, type LifeDomainScore } from '@/lib/types/leefgebieden';
|
||||
|
||||
// Input validation schema
|
||||
const GenerateInputSchema = z.object({
|
||||
patientId: z.string().uuid('Patient ID moet een geldige UUID zijn'),
|
||||
intakeId: z.string().uuid('Intake ID moet een geldige UUID zijn'),
|
||||
conditionId: z.string().uuid().optional(),
|
||||
extraInstructions: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Map severity_code to Severity type
|
||||
*/
|
||||
function mapSeverity(severityCode: string | null | undefined): Severity {
|
||||
if (!severityCode) return 'middel';
|
||||
|
||||
const code = severityCode.toLowerCase();
|
||||
if (code.includes('mild') || code.includes('laag') || code.includes('light')) {
|
||||
return 'laag';
|
||||
}
|
||||
if (code.includes('severe') || code.includes('hoog') || code.includes('ernstig')) {
|
||||
return 'hoog';
|
||||
}
|
||||
return 'middel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load context from database
|
||||
*/
|
||||
async function loadPlanContext(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
patientId: string,
|
||||
intakeId: string,
|
||||
conditionId?: string
|
||||
): Promise<PlanContext> {
|
||||
// 1. Load intake
|
||||
const { data: intake, error: intakeError } = await supabase
|
||||
.from('intakes')
|
||||
.select('*')
|
||||
.eq('id', intakeId)
|
||||
.eq('patient_id', patientId)
|
||||
.single();
|
||||
|
||||
if (intakeError || !intake) {
|
||||
throw new Error(`Intake niet gevonden: ${intakeError?.message || 'niet gevonden'}`);
|
||||
}
|
||||
|
||||
// 2. Load condition (latest for patient, or specific one)
|
||||
let conditionQuery = supabase
|
||||
.from('conditions')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId);
|
||||
|
||||
if (conditionId) {
|
||||
conditionQuery = conditionQuery.eq('id', conditionId);
|
||||
} else {
|
||||
conditionQuery = conditionQuery.order('recorded_date', { ascending: false }).limit(1);
|
||||
}
|
||||
|
||||
const { data: conditions } = await conditionQuery;
|
||||
const condition = conditions?.[0];
|
||||
|
||||
// 3. Build intake notes from available data
|
||||
const intakeNotes = buildIntakeNotes(intake);
|
||||
|
||||
// 4. Get life domains (from intake or default)
|
||||
const lifeDomains: LifeDomainScore[] =
|
||||
(intake.life_domains as LifeDomainScore[] | null) ||
|
||||
createDefaultLifeDomainScores();
|
||||
|
||||
// 5. Build context
|
||||
return {
|
||||
patientId,
|
||||
intakeNotes,
|
||||
dsmCategory: condition?.category || condition?.code_display || 'overig',
|
||||
severity: mapSeverity(condition?.severity_code),
|
||||
lifeDomains,
|
||||
extraInstructions: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build intake notes from intake data
|
||||
*/
|
||||
function buildIntakeNotes(intake: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (intake.notes) {
|
||||
parts.push(String(intake.notes));
|
||||
}
|
||||
|
||||
if (intake.treatment_advice) {
|
||||
const advice = intake.treatment_advice as Record<string, unknown>;
|
||||
if (advice.content) {
|
||||
parts.push(`Behandeladvies: ${String(advice.content)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (intake.kindcheck_data) {
|
||||
const kindcheck = intake.kindcheck_data as Record<string, unknown>;
|
||||
if (kindcheck.observations) {
|
||||
parts.push(`Observaties: ${String(kindcheck.observations)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n') || 'Geen intake notities beschikbaar.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Claude API
|
||||
*/
|
||||
async function callClaudeAPI(context: PlanContext): Promise<GeneratedPlan> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('ANTHROPIC_API_KEY ontbreekt in environment');
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt(context);
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.3,
|
||||
system: BEHANDELPLAN_SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
console.error('Claude API error:', errorBody);
|
||||
throw new Error(`Claude API fout: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawText = data?.content?.[0]?.text;
|
||||
|
||||
if (!rawText) {
|
||||
throw new Error('Geen response van Claude API');
|
||||
}
|
||||
|
||||
// Parse JSON from response (handle potential markdown code blocks)
|
||||
let jsonText = rawText.trim();
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7);
|
||||
}
|
||||
if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3);
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonText.trim());
|
||||
|
||||
// Validate with Zod schema
|
||||
const validated = GeneratedPlanSchema.parse(parsed);
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log AI event to database
|
||||
*/
|
||||
async function logAIEvent(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
kind: string,
|
||||
patientId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: Record<string, unknown>,
|
||||
durationMs: number
|
||||
) {
|
||||
try {
|
||||
await supabase.from('ai_events').insert({
|
||||
kind,
|
||||
patient_id: patientId,
|
||||
input_data: input,
|
||||
output_data: output,
|
||||
duration_ms: durationMs,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail the request
|
||||
console.error('Failed to log AI event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/behandelplan/generate
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input
|
||||
const result = GenerateInputSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validatiefout',
|
||||
details: result.error.issues.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { patientId, intakeId, conditionId, extraInstructions } = result.data;
|
||||
const supabase = await createClient();
|
||||
|
||||
// Load context from database
|
||||
const context = await loadPlanContext(supabase, patientId, intakeId, conditionId);
|
||||
|
||||
// Add extra instructions if provided
|
||||
if (extraInstructions) {
|
||||
context.extraInstructions = extraInstructions;
|
||||
}
|
||||
|
||||
// Validate context
|
||||
const validation = validatePlanContext(context);
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Onvoldoende context voor behandelplan generatie',
|
||||
details: validation.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call Claude API
|
||||
const plan = await callClaudeAPI(context);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Log AI event
|
||||
await logAIEvent(
|
||||
supabase,
|
||||
'behandelplan_generate',
|
||||
patientId,
|
||||
{ intakeId, conditionId, extraInstructions },
|
||||
{ goalCount: plan.doelen.length, interventionCount: plan.interventies.length },
|
||||
durationMs
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationMs,
|
||||
context: {
|
||||
dsmCategory: context.dsmCategory,
|
||||
severity: context.severity,
|
||||
highPriorityDomains: context.lifeDomains
|
||||
.filter((d) => d.priority === 'hoog')
|
||||
.map((d) => d.domain),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating behandelplan:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Onbekende fout';
|
||||
|
||||
// Check for specific error types
|
||||
if (errorMessage.includes('Intake niet gevonden')) {
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage.includes('ANTHROPIC_API_KEY')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI service niet geconfigureerd' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Claude API')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI service tijdelijk niet beschikbaar', details: errorMessage },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Fout bij genereren behandelplan',
|
||||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -207,8 +207,8 @@ Je herkent de volgende gebruikersintenties en voert acties uit:
|
||||
- Actie: Toon DiagnoseBlock met ICD-10 codes
|
||||
|
||||
- **intake_navigeer** — Navigeer naar intake sectie
|
||||
- Triggers: "ga naar risico", "ga naar diagnose", "ga naar kindcheck", "ga naar anamnese", "open risicotaxatie", "naar behandeladvies"
|
||||
- Entities: navigationTarget (risk/diagnosis/kindcheck/anamnese/contacts/examination/rom/behandeladvies)
|
||||
- Triggers: "ga naar risico", "ga naar diagnose", "ga naar kindcheck", "open risicotaxatie", "naar behandeladvies"
|
||||
- Entities: navigationTarget (risk/diagnosis/kindcheck/contacts/behandeladvies)
|
||||
- Required: navigationTarget, actieve patient
|
||||
- Actie: Navigeer naar de intake sectie in het EPD
|
||||
|
||||
|
||||
@@ -26,11 +26,8 @@ interface IntakeSection {
|
||||
|
||||
const INTAKE_SECTIONS: IntakeSection[] = [
|
||||
{ id: 'contacts', label: 'Contactmomenten', required: false },
|
||||
{ id: 'anamnese', label: 'Anamnese', required: true },
|
||||
{ id: 'risk', label: 'Risicotaxatie', required: true },
|
||||
{ id: 'kindcheck', label: 'Kindcheck', required: true },
|
||||
{ id: 'examination', label: 'Onderzoek', required: false },
|
||||
{ id: 'rom', label: 'Meetinstrumenten (ROM)', required: false },
|
||||
{ id: 'diagnosis', label: 'Diagnose', required: true },
|
||||
{ id: 'behandeladvies', label: 'Behandeladvies', required: true },
|
||||
];
|
||||
@@ -139,41 +136,17 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Fetch counts for each section in parallel
|
||||
const [
|
||||
contactsResult,
|
||||
anamneseResult,
|
||||
riskResult,
|
||||
examinationResult,
|
||||
romResult,
|
||||
diagnosisResult,
|
||||
] = await Promise.all([
|
||||
const [contactsResult, riskResult, diagnosisResult] = await Promise.all([
|
||||
// Contacts (encounters)
|
||||
supabase
|
||||
.from('encounters')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('intake_id', targetIntakeId),
|
||||
// Anamnese
|
||||
supabase
|
||||
.from('anamneses')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('intake_id', targetIntakeId),
|
||||
// Risk assessments
|
||||
supabase
|
||||
.from('risk_assessments')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('intake_id', targetIntakeId),
|
||||
// Examinations (non-ROM)
|
||||
supabase
|
||||
.from('examinations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('intake_id', targetIntakeId)
|
||||
.neq('examination_type', 'ROM'),
|
||||
// ROM examinations
|
||||
supabase
|
||||
.from('examinations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('intake_id', targetIntakeId)
|
||||
.eq('examination_type', 'ROM'),
|
||||
// Diagnoses (conditions linked to intake via encounter_id)
|
||||
supabase
|
||||
.from('conditions')
|
||||
@@ -184,11 +157,8 @@ export async function GET(request: NextRequest) {
|
||||
// Build section status
|
||||
const sectionCounts: Record<string, number> = {
|
||||
contacts: contactsResult.count || 0,
|
||||
anamnese: anamneseResult.count || 0,
|
||||
risk: riskResult.count || 0,
|
||||
kindcheck: intake.kindcheck_data && Object.keys(intake.kindcheck_data as object).length > 0 ? 1 : 0,
|
||||
examination: examinationResult.count || 0,
|
||||
rom: romResult.count || 0,
|
||||
diagnosis: diagnosisResult.count || 0,
|
||||
behandeladvies: intake.treatment_advice && Object.keys(intake.treatment_advice as object).length > 0 ? 1 : 0,
|
||||
};
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Leads API Endpoint
|
||||
*
|
||||
* POST /api/leads - Submit contact form lead
|
||||
*
|
||||
* Security:
|
||||
* - Rate limiting via headers
|
||||
* - Input validation
|
||||
* - Server-side Supabase client
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Validation schema
|
||||
const leadSchema = z.object({
|
||||
name: z.string().min(2, 'Naam moet minimaal 2 karakters zijn'),
|
||||
email: z.string().email('Ongeldig email adres'),
|
||||
company: z.string().optional(),
|
||||
projectType: z.string().min(1, 'Selecteer een project type'),
|
||||
budget: z.string().optional(),
|
||||
message: z.string().min(20, 'Beschrijving moet minimaal 20 karakters zijn'),
|
||||
})
|
||||
|
||||
export type LeadFormData = z.infer<typeof leadSchema>
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = leadSchema.parse(body)
|
||||
|
||||
// Get client IP and user agent for tracking
|
||||
const ip = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown'
|
||||
const referrer = request.headers.get('referer') || 'direct'
|
||||
|
||||
// Insert lead into database (using admin client for server-side insert)
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('leads')
|
||||
.insert({
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
company: validatedData.company || null,
|
||||
project_type: validatedData.projectType,
|
||||
budget: validatedData.budget || null,
|
||||
message: validatedData.message,
|
||||
status: 'new',
|
||||
ip_address: ip,
|
||||
user_agent: userAgent,
|
||||
referrer: referrer,
|
||||
source: 'website',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Database error', details: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return success
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Lead ontvangen',
|
||||
leadId: data.id
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
// Validation error
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validatie fout',
|
||||
issues: error.issues.map(e => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message
|
||||
}))
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generic error
|
||||
console.error('Unexpected error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Interne server fout' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS for CORS (if needed)
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4,18 +4,11 @@ import { createClient } from '@/lib/auth/server';
|
||||
import { getPatient } from '@/app/epd/patients/actions';
|
||||
import { getIntakesByPatientId } from '@/app/epd/patients/[id]/intakes/actions';
|
||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||
import { getActiveCarePlan } from '@/app/epd/patients/[id]/behandelplan/actions';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ patientId: string }>;
|
||||
}
|
||||
|
||||
function extractHulpvraag(notes: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const firstSentence = notes.split(/[.!?]/)[0];
|
||||
return firstSentence.length > 150 ? `${firstSentence.slice(0, 150)}...` : firstSentence;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { patientId } = await params;
|
||||
@@ -38,26 +31,15 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Patiënt niet gevonden' }, { status: 404 });
|
||||
}
|
||||
|
||||
const [intakes, encounters, carePlan] = await Promise.all([
|
||||
const [intakes, encounters] = await Promise.all([
|
||||
getIntakesByPatientId(patientId).catch(() => []),
|
||||
getPatientEncounters(patientId).catch(() => []),
|
||||
getActiveCarePlan(patientId).catch(() => null),
|
||||
]);
|
||||
|
||||
let hulpvraag: string | null = null;
|
||||
if (carePlan?.based_on_intake_id) {
|
||||
const linkedIntake = intakes.find((intake) => intake.id === carePlan.based_on_intake_id);
|
||||
if (linkedIntake?.notes) {
|
||||
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
patient,
|
||||
intakes,
|
||||
encounters,
|
||||
carePlan,
|
||||
hulpvraag,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in GET /api/patients/[patientId]/dashboard:', error);
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/set-password', request.url))
|
||||
}
|
||||
|
||||
// Redirect to the specified next URL or default to /epd/clients
|
||||
// Redirect to the specified next URL or default to /epd/patients
|
||||
// This includes: password signups, confirmed email users, and returning users
|
||||
return NextResponse.redirect(new URL(next, request.url))
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Catch-all Redirect Route for /epd/clients/
|
||||
*
|
||||
* Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility
|
||||
* This ensures that old bookmarks and links continue to work after migration.
|
||||
*
|
||||
* Preserves query parameters (e.g., ?tab=intake&search=test)
|
||||
*/
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// Build new path with query parameters
|
||||
const newPath = `/epd/patients/${path.join('/')}`;
|
||||
const newUrl = new URL(newPath, request.url);
|
||||
|
||||
// Preserve all query parameters
|
||||
searchParams.forEach((value, key) => {
|
||||
newUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
redirect(newUrl.toString());
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FileText, User, Target } from 'lucide-react';
|
||||
import { IntakeTab } from './intake-tab';
|
||||
import { ProfileTab } from './profile-tab';
|
||||
import { PlanTab } from './plan-tab';
|
||||
|
||||
interface ClientTabsProps {
|
||||
clientId: string;
|
||||
activeTab?: string;
|
||||
}
|
||||
|
||||
type TabId = 'intake' | 'profile' | 'plan';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'intake' as TabId,
|
||||
label: 'Intake',
|
||||
icon: FileText,
|
||||
description: 'Intake gesprekken en notities',
|
||||
},
|
||||
{
|
||||
id: 'profile' as TabId,
|
||||
label: 'Profiel',
|
||||
icon: User,
|
||||
description: 'Probleemprofielen en DSM categorieën',
|
||||
},
|
||||
{
|
||||
id: 'plan' as TabId,
|
||||
label: 'Behandelplan',
|
||||
icon: Target,
|
||||
description: 'Behandeldoelen en interventies',
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientTabs({ clientId, activeTab }: ClientTabsProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [currentTab, setCurrentTab] = useState<TabId>(
|
||||
(activeTab as TabId) || 'intake'
|
||||
);
|
||||
|
||||
const handleTabChange = (tabId: TabId) => {
|
||||
setCurrentTab(tabId);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('tab', tabId);
|
||||
router.push(`/epd/clients/${clientId}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-1">
|
||||
<div className="flex gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = currentTab === tab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-md text-sm font-medium transition-all
|
||||
${
|
||||
isActive
|
||||
? 'bg-teal-50 text-teal-700 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-lg border border-slate-200">
|
||||
{currentTab === 'intake' && <IntakeTab clientId={clientId} />}
|
||||
{currentTab === 'profile' && <ProfileTab clientId={clientId} />}
|
||||
{currentTab === 'plan' && <PlanTab clientId={clientId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { getIntakesByClientId, Intake } from '../intakes/actions';
|
||||
import { IntakeList } from '../intakes/components/intake-list';
|
||||
|
||||
interface IntakeTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function IntakeTab({ clientId }: IntakeTabProps) {
|
||||
const [intakes, setIntakes] = useState<Intake[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchIntakes() {
|
||||
try {
|
||||
const data = await getIntakesByClientId(clientId);
|
||||
setIntakes(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch intakes:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchIntakes();
|
||||
}, [clientId]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Intakes
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Overzicht van alle intakes
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/epd/clients/${clientId}/intakes/new`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Nieuwe Intake</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<IntakeList intakes={intakes} clientId={clientId} isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Target, Sparkles, Clock } from 'lucide-react';
|
||||
|
||||
interface PlanTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function PlanTab({ clientId }: PlanTabProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Behandelplan
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
SMART doelen en interventies
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-400 font-medium rounded-lg cursor-not-allowed"
|
||||
title="Beschikbaar in Week 3"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Genereer Plan</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon State */}
|
||||
<div className="py-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-50 mb-4">
|
||||
<Target className="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||
Coming Soon - Week 3
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 max-w-md mx-auto mb-6">
|
||||
AI-gegenereerde behandelplannen met SMART doelen en evidence-based
|
||||
interventies worden toegevoegd in Week 3.
|
||||
</p>
|
||||
|
||||
{/* Feature Preview */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-slate-50 rounded-lg border border-slate-200 p-6 text-left">
|
||||
<h4 className="font-medium text-slate-900 mb-3">
|
||||
🎯 Plan Structuur:
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{/* SMART Doelen */}
|
||||
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h5 className="font-medium text-slate-800 mb-2">
|
||||
1. SMART Doelen
|
||||
</h5>
|
||||
<p className="text-sm text-slate-600">
|
||||
Specifieke, Meetbare, Acceptabele, Realistische en
|
||||
Tijdgebonden behandeldoelen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interventies */}
|
||||
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h5 className="font-medium text-slate-800 mb-2">
|
||||
2. Interventies
|
||||
</h5>
|
||||
<p className="text-sm text-slate-600">
|
||||
Evidence-based behandelmethoden (CGT, ACT, EMDR, etc.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Frequentie */}
|
||||
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h5 className="font-medium text-slate-800 mb-2">
|
||||
3. Frequentie
|
||||
</h5>
|
||||
<p className="text-sm text-slate-600">
|
||||
Sessie planning en behandelintensiteit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Meetmomenten */}
|
||||
<div className="p-4 bg-white rounded-lg border border-slate-200">
|
||||
<h5 className="font-medium text-slate-800 mb-2">
|
||||
4. Meetmomenten
|
||||
</h5>
|
||||
<p className="text-sm text-slate-600">
|
||||
Evaluatie en voortgangsmetingen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-slate-900 mb-3 mt-6">
|
||||
✨ Geplande Features:
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>AI Plan Generator</strong> - Gebaseerd op intake +
|
||||
profiel
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Versioning</strong> - Meerdere versies per cliënt
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Status Tracking</strong> - Concept vs. Gepubliceerd
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>JSONB Opslag</strong> - Flexibele datastructuur
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Export Functie</strong> - PDF generatie voor dossier
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Preview */}
|
||||
<div className="mt-8 inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full text-sm">
|
||||
<Clock className="h-4 w-4 text-teal-600" />
|
||||
<span className="text-teal-800">
|
||||
<strong>Week 3:</strong> 18-24 November 2024
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { User, Brain, Clock } from 'lucide-react';
|
||||
|
||||
interface ProfileTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function ProfileTab({ clientId }: ProfileTabProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Probleemprofiel
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
DSM-light categorisatie en ernst indicatie
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-400 font-medium rounded-lg cursor-not-allowed"
|
||||
title="Beschikbaar in Week 3"
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
<span>AI Analyse</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon State */}
|
||||
<div className="py-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-50 mb-4">
|
||||
<User className="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||
Coming Soon - Week 3
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 max-w-md mx-auto mb-6">
|
||||
AI-gestuurde probleemclassificatie met DSM-light categorieën wordt
|
||||
toegevoegd in Week 3.
|
||||
</p>
|
||||
|
||||
{/* Feature Preview */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-slate-50 rounded-lg border border-slate-200 p-6 text-left">
|
||||
<h4 className="font-medium text-slate-900 mb-3">
|
||||
🎯 DSM-light Categorieën:
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-700">Stemming & Depressie</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
<span className="text-slate-700">Angst</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span className="text-slate-700">Gedrag & Impuls</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||
<span className="text-slate-700">Middelengebruik</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-slate-700">Cognitief</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="h-2 w-2 rounded-full bg-teal-500" />
|
||||
<span className="text-slate-700">Context & Psychosociaal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-slate-900 mb-3 mt-6">
|
||||
📊 Geplande Features:
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>AI Categorisatie</strong> - Automatische DSM-light
|
||||
classificatie
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Ernst Indicatie</strong> - Laag, Middel, Hoog scoring
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Visuele Dashboard</strong> - Overzichtelijke
|
||||
weergave per categorie
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-teal-600 font-bold">✓</span>
|
||||
<span>
|
||||
<strong>Bronverwijzing</strong> - Link naar intake notities
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Preview */}
|
||||
<div className="mt-8 inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full text-sm">
|
||||
<Clock className="h-4 w-4 text-teal-600" />
|
||||
<span className="text-teal-800">
|
||||
<strong>Week 3:</strong> 18-24 November 2024
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Client Dashboard - Level 2 (Client Dossier)
|
||||
*
|
||||
* Overzicht van client voortgang, recente activiteit en belangrijke metrics.
|
||||
* Dit is de hoofd-dashboard pagina die opent wanneer je een client selecteert.
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { getClient } from '../../actions';
|
||||
import { transformClient } from '@/lib/types/client';
|
||||
import { ClientTabs } from '../components/client-tabs';
|
||||
|
||||
interface ClientDashboardPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}
|
||||
|
||||
export default async function ClientDashboardPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: ClientDashboardPageProps) {
|
||||
const { id } = await params;
|
||||
const { tab } = await searchParams;
|
||||
|
||||
// Fetch client data
|
||||
let client;
|
||||
try {
|
||||
client = await getClient(id);
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const clientWithAge = transformClient(client);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href="/epd/clients"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Terug naar cliënten</span>
|
||||
</Link>
|
||||
|
||||
{/* Client Info */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center shadow-md">
|
||||
<span className="text-white font-semibold text-xl">
|
||||
{client.first_name[0]}
|
||||
{client.last_name[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name & Info */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
{clientWithAge.full_name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-slate-600">
|
||||
<span>{clientWithAge.age} jaar</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Geboren:{' '}
|
||||
{new Date(client.birth_date).toLocaleDateString('nl-NL')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/epd/clients/${client.id}/edit`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Bewerken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-6">
|
||||
<ClientTabs clientId={client.id} activeTab={tab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Diagnose & Probleemprofiel - Level 2 (Client Dossier)
|
||||
*
|
||||
* DSM-light categorieën met severity tracking.
|
||||
*/
|
||||
|
||||
export default function DiagnosePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Diagnose & Probleemprofiel
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
DSM-light Categorieën
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
6 categorieën met severity indicators (Laag/Middel/Hoog)
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Epic 4 - Placeholder for future content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { getClient } from '../../actions';
|
||||
import { ClientForm } from '../../components/client-form';
|
||||
|
||||
interface EditClientPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch client data
|
||||
let client;
|
||||
try {
|
||||
client = await getClient(id);
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-2xl mx-auto">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href={`/epd/clients/${id}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-6 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Terug naar cliënt</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Cliënt bewerken</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Wijzig de gegevens van {client.first_name} {client.last_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ClientForm client={client} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Intake Sectie - Level 2 (Client Dossier)
|
||||
*
|
||||
* TipTap editor voor intake notities met CRUD functionaliteit.
|
||||
*/
|
||||
|
||||
export default function IntakePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Intakes
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
Intake Notities
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
TipTap editor, CRUD voor intakes, slide-in detail panel
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Epic 3 - Placeholder for future content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Intake } from '../../actions';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { Calendar, Clock, FileText } from 'lucide-react';
|
||||
|
||||
interface IntakeHeaderProps {
|
||||
intake: Intake;
|
||||
}
|
||||
|
||||
export function IntakeHeader({ intake }: IntakeHeaderProps) {
|
||||
const statusColors = {
|
||||
Open: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
Completed: 'bg-green-50 text-green-700 border-green-200',
|
||||
Cancelled: 'bg-red-50 text-red-700 border-red-200',
|
||||
Draft: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
const status = intake.status || 'Draft';
|
||||
const statusClass = statusColors[status as keyof typeof statusColors] || statusColors.Draft;
|
||||
|
||||
return (
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-teal-50 rounded-lg text-teal-600">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-xl font-bold text-slate-900">{intake.title}</h1>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-700">{intake.department}</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
Start: {format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
|
||||
</span>
|
||||
</div>
|
||||
{intake.end_date && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Eind: {format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Placeholder for actions like Edit, Close, etc. */}
|
||||
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md transition-colors">
|
||||
Bewerken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface IntakeTabsProps {
|
||||
clientId: string;
|
||||
intakeId: string;
|
||||
}
|
||||
|
||||
export function IntakeTabs({ clientId, intakeId }: IntakeTabsProps) {
|
||||
const pathname = usePathname();
|
||||
const baseUrl = `/epd/clients/${clientId}/intakes/${intakeId}`;
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Algemeen', href: baseUrl, exact: true },
|
||||
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
|
||||
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
|
||||
{ name: 'Risicotaxatie', href: `${baseUrl}/risk` },
|
||||
{ name: 'Anamnese', href: `${baseUrl}/anamnese` },
|
||||
{ name: 'Onderzoek', href: `${baseUrl}/examination` },
|
||||
{ name: 'Diagnose & Advies', href: `${baseUrl}/diagnosis` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-slate-200 bg-white px-6">
|
||||
<nav className="-mb-px flex space-x-6 overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.exact
|
||||
? pathname === tab.href
|
||||
: pathname.startsWith(tab.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getIntakeById } from '../actions';
|
||||
import { IntakeHeader } from './components/intake-header';
|
||||
import { IntakeTabs } from './components/intake-tabs';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface IntakeLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ id: string; intakeId: string }>;
|
||||
}
|
||||
|
||||
export default async function IntakeLayout({ children, params }: IntakeLayoutProps) {
|
||||
const { id, intakeId } = await params;
|
||||
const intake = await getIntakeById(intakeId);
|
||||
|
||||
if (!intake) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-50">
|
||||
<IntakeHeader intake={intake} />
|
||||
<IntakeTabs clientId={id} intakeId={intakeId} />
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { getIntakeById } from '../actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface IntakePageProps {
|
||||
params: Promise<{ intakeId: string }>;
|
||||
}
|
||||
|
||||
export default async function IntakePage({ params }: IntakePageProps) {
|
||||
const { intakeId } = await params;
|
||||
const intake = await getIntakeById(intakeId);
|
||||
|
||||
if (!intake) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Algemene Informatie</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Titel</label>
|
||||
<p className="text-slate-900">{intake.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Afdeling</label>
|
||||
<p className="text-slate-900">{intake.department}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
|
||||
<p className="text-slate-900">{intake.status}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Startdatum</label>
|
||||
<p className="text-slate-900">{intake.start_date}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-slate-100">
|
||||
<label className="block text-sm font-medium text-slate-500 mb-2">Notities</label>
|
||||
<div className="bg-slate-50 rounded-md p-4 text-slate-600 text-sm min-h-[100px]">
|
||||
{intake.notes || 'Geen notities beschikbaar.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { Database } from '@/lib/supabase/database.types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type Intake = Database['public']['Tables']['intakes']['Row'];
|
||||
|
||||
const CreateIntakeSchema = z.object({
|
||||
title: z.string().min(1, 'Titel is verplicht'),
|
||||
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']),
|
||||
start_date: z.string().min(1, 'Startdatum is verplicht'),
|
||||
patient_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type CreateIntakeInput = z.infer<typeof CreateIntakeSchema>;
|
||||
|
||||
export async function getIntakesByClientId(clientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('intakes')
|
||||
.select('*')
|
||||
.eq('patient_id', clientId)
|
||||
.order('start_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching intakes:', error);
|
||||
throw new Error('Failed to fetch intakes');
|
||||
}
|
||||
|
||||
return data as Intake[];
|
||||
}
|
||||
|
||||
export async function createIntake(input: CreateIntakeInput) {
|
||||
const result = CreateIntakeSchema.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Invalid input data');
|
||||
}
|
||||
|
||||
const { title, department, start_date, patient_id } = result.data;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('intakes')
|
||||
.insert({
|
||||
title,
|
||||
department,
|
||||
start_date,
|
||||
patient_id,
|
||||
status: 'Open', // Default status
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating intake:', error);
|
||||
throw new Error('Failed to create intake');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/clients/${patient_id}`);
|
||||
redirect(`/epd/clients/${patient_id}?tab=intake`);
|
||||
}
|
||||
|
||||
export async function getIntakeById(intakeId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('intakes')
|
||||
.select('*')
|
||||
.eq('id', intakeId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching intake:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Intake;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Calendar, ChevronRight, FileText } from 'lucide-react';
|
||||
import { Intake } from '../actions';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
interface IntakeCardProps {
|
||||
intake: Intake;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function IntakeCard({ intake, clientId }: IntakeCardProps) {
|
||||
const statusColors = {
|
||||
Open: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
Completed: 'bg-green-50 text-green-700 border-green-200',
|
||||
Cancelled: 'bg-red-50 text-red-700 border-red-200',
|
||||
Draft: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
const status = intake.status || 'Draft';
|
||||
const statusClass = statusColors[status as keyof typeof statusColors] || statusColors.Draft;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/epd/clients/${clientId}/intakes/${intake.id}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-teal-500 hover:shadow-sm transition-all">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-50 rounded-md text-teal-600 group-hover:bg-teal-100 transition-colors">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900 group-hover:text-teal-700 transition-colors">
|
||||
{intake.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{intake.department}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500 mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
|
||||
</span>
|
||||
</div>
|
||||
{intake.end_date && (
|
||||
<>
|
||||
<span>→</span>
|
||||
<span>
|
||||
{format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<ChevronRight className="h-4 w-4 text-slate-300 group-hover:text-teal-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Intake } from '../actions';
|
||||
import { IntakeCard } from './intake-card';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
interface IntakeListProps {
|
||||
intakes: Intake[];
|
||||
clientId: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function IntakeList({ intakes, clientId, isLoading }: IntakeListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-32 bg-slate-50 rounded-lg border border-slate-200 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (intakes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-lg border border-dashed border-slate-300">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
|
||||
<FileText className="h-6 w-6 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-slate-900 mb-1">
|
||||
Geen intakes gevonden
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Start een nieuwe intake om te beginnen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{intakes.map((intake) => (
|
||||
<IntakeCard key={intake.id} intake={intake} clientId={clientId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { createIntake } from '../actions';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { CalendarIcon, Loader2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Titel is verplicht'),
|
||||
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']),
|
||||
start_date: z.string().min(1, 'Startdatum is verplicht'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
interface NewIntakeFormProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function NewIntakeForm({ clientId }: NewIntakeFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
department: 'Volwassenen',
|
||||
start_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createIntake({
|
||||
...data,
|
||||
patient_id: clientId,
|
||||
});
|
||||
} catch (e) {
|
||||
setError('Er is een fout opgetreden bij het aanmaken van de intake.');
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-md">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="title" className="text-sm font-medium text-slate-900">
|
||||
Titel Intake
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
{...register('title')}
|
||||
placeholder="Bijv. Intake Depressie"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
errors.title && "border-red-500 focus:ring-red-500"
|
||||
)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-xs text-red-500">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="department" className="text-sm font-medium text-slate-900">
|
||||
Afdeling
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register('department')}
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="Volwassenen">Volwassenen</option>
|
||||
<option value="Jeugd">Jeugd</option>
|
||||
<option value="Ouderen">Ouderen</option>
|
||||
</select>
|
||||
{errors.department && (
|
||||
<p className="text-xs text-red-500">{errors.department.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="start_date" className="text-sm font-medium text-slate-900">
|
||||
Startdatum
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="start_date"
|
||||
type="date"
|
||||
{...register('start_date')}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
errors.start_date && "border-red-500 focus:ring-red-500"
|
||||
)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
{errors.start_date && (
|
||||
<p className="text-xs text-red-500">{errors.start_date.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isPending ? 'Aanmaken...' : 'Start Intake'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NewIntakeForm } from '../../components/new-intake-form';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
interface NewIntakePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function NewIntakePage({ params }: NewIntakePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/epd/clients/${id}?tab=intake`}
|
||||
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-900 mb-4 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Terug naar overzicht
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Nieuwe Intake Starten</h1>
|
||||
<p className="text-slate-600 mt-2">
|
||||
Vul de basisgegevens in om een nieuwe intake te starten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
|
||||
<NewIntakeForm clientId={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface ClientDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect route: /epd/clients/[id] -> /epd/clients/[id]/dashboard
|
||||
*
|
||||
* Wanneer een gebruiker direct naar /epd/clients/[id] navigeert,
|
||||
* wordt deze automatisch doorgestuurd naar het dashboard.
|
||||
*/
|
||||
export default async function ClientRedirect({
|
||||
params,
|
||||
}: ClientDetailPageProps) {
|
||||
const { id } = await params;
|
||||
redirect(`/epd/clients/${id}/dashboard`);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Behandelplan - Level 2 (Client Dossier)
|
||||
*
|
||||
* SMART doelen tracking met interventies en versioning.
|
||||
*/
|
||||
|
||||
export default function PlanPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Behandelplan
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
SMART Doelen & Interventies
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Treatment plan met versioning (v1, v2, concept/gepubliceerd)
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Epic 5 - Placeholder for future content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Client Rapportage - Level 2 (Client Dossier)
|
||||
*
|
||||
* Client-specifieke voortgang en metrics.
|
||||
*/
|
||||
|
||||
export default function ClientReportsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Rapportage & Voortgang
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
Client Voortgang
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Behandelduur, sessies, doelvoortgang tracking
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Placeholder - Not designed yet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* Client CRUD Server Actions
|
||||
*
|
||||
* Server-side actions for client management
|
||||
*/
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { createClient as createSupabaseClient } from '@/lib/auth/server';
|
||||
import type { ClientFormData, ClientFilters } from '@/lib/types/client';
|
||||
|
||||
/**
|
||||
* Get all clients with optional filtering
|
||||
*/
|
||||
export async function getClients(filters?: ClientFilters) {
|
||||
const supabase = await createSupabaseClient();
|
||||
|
||||
let query = supabase
|
||||
.from('clients')
|
||||
.select('*');
|
||||
|
||||
// Apply search filter
|
||||
if (filters?.search) {
|
||||
const searchTerm = filters.search.trim();
|
||||
// Use PostgREST or() syntax: column.operator.value,column.operator.value
|
||||
// Format: column.operator.value,column.operator.value (no quotes needed for ilike)
|
||||
const searchPattern = `%${searchTerm}%`;
|
||||
query = query.or(`first_name.ilike.${searchPattern},last_name.ilike.${searchPattern}`);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
const sortBy = filters?.sortBy || 'created_at';
|
||||
const sortOrder = filters?.sortOrder || 'desc';
|
||||
|
||||
if (sortBy === 'name') {
|
||||
query = query.order('last_name', { ascending: sortOrder === 'asc' });
|
||||
query = query.order('first_name', { ascending: sortOrder === 'asc' });
|
||||
} else if (sortBy === 'created_at') {
|
||||
query = query.order('created_at', { ascending: sortOrder === 'asc' });
|
||||
} else if (sortBy === 'age') {
|
||||
// Sort by birth_date (newest birth = youngest age)
|
||||
query = query.order('birth_date', { ascending: sortOrder === 'desc' });
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching clients:', error);
|
||||
throw new Error('Failed to fetch clients');
|
||||
}
|
||||
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single client by ID
|
||||
*/
|
||||
export async function getClient(id: string) {
|
||||
const supabase = await createSupabaseClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('clients')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching client:', error);
|
||||
throw new Error('Failed to fetch client');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new client
|
||||
*/
|
||||
export async function createClient(formData: ClientFormData) {
|
||||
const supabase = await createSupabaseClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('clients')
|
||||
.insert({
|
||||
first_name: formData.first_name.trim(),
|
||||
last_name: formData.last_name.trim(),
|
||||
birth_date: formData.birth_date,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating client:', error);
|
||||
throw new Error('Failed to create client');
|
||||
}
|
||||
|
||||
revalidatePath('/epd/clients');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing client
|
||||
*/
|
||||
export async function updateClient(id: string, formData: ClientFormData) {
|
||||
const supabase = await createSupabaseClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('clients')
|
||||
.update({
|
||||
first_name: formData.first_name.trim(),
|
||||
last_name: formData.last_name.trim(),
|
||||
birth_date: formData.birth_date,
|
||||
})
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating client:', error);
|
||||
throw new Error('Failed to update client');
|
||||
}
|
||||
|
||||
revalidatePath('/epd/clients');
|
||||
revalidatePath(`/epd/clients/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete client
|
||||
*/
|
||||
export async function deleteClient(id: string) {
|
||||
const supabase = await createSupabaseClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('clients')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting client:', error);
|
||||
throw new Error('Failed to delete client');
|
||||
}
|
||||
|
||||
revalidatePath('/epd/clients');
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { CheckCircle2, Circle, Clock, Rocket } from "lucide-react"
|
||||
|
||||
export default function ComingSoonPage() {
|
||||
// Roadmap items from bouwplan v2.1
|
||||
const roadmapItems = [
|
||||
{
|
||||
week: "Week 1",
|
||||
status: "in-progress",
|
||||
title: "Foundation & Marketing",
|
||||
items: [
|
||||
{ done: true, text: "Project Setup - Next.js + Supabase" },
|
||||
{ done: true, text: "Design System - Teal-first kleuren" },
|
||||
{ done: true, text: "App Layout - Header + Sidebar" },
|
||||
{ done: false, text: "Marketing Website - Timeline + Features" },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: "Week 2",
|
||||
status: "upcoming",
|
||||
title: "EPD Core",
|
||||
items: [
|
||||
{ done: false, text: "Database Schema + RLS Policies" },
|
||||
{ done: false, text: "Client Module - CRUD Operations" },
|
||||
{ done: false, text: "Client Detail Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: "Week 3",
|
||||
status: "upcoming",
|
||||
title: "AI Magic",
|
||||
items: [
|
||||
{ done: false, text: "TipTap Rich Text Editor" },
|
||||
{ done: false, text: "Claude API - Intake Samenvatting" },
|
||||
{ done: false, text: "AI Profiel + Behandelplan Generator" },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: "Week 4",
|
||||
status: "upcoming",
|
||||
title: "Polish & Launch",
|
||||
items: [
|
||||
{ done: false, text: "Onboarding System" },
|
||||
{ done: false, text: "Performance Optimization" },
|
||||
{ done: false, text: "Demo Preparation + LinkedIn Launch" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-5xl mx-auto">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-amber-500 to-amber-600 mb-6 shadow-lg">
|
||||
<Rocket className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-slate-900 mb-4">
|
||||
Coming Soon
|
||||
</h1>
|
||||
<p className="text-xl text-slate-600 max-w-2xl mx-auto mb-2">
|
||||
Het EPD wordt gebouwd in <span className="font-semibold text-teal-700">4 weken</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Van €100.000+ en 12-24 maanden → <span className="font-mono font-semibold text-amber-700">€200 + 4 weken</span>
|
||||
</p>
|
||||
|
||||
{/* Build in Public Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full">
|
||||
<div className="h-2 w-2 rounded-full bg-teal-500 animate-pulse" />
|
||||
<span className="text-sm font-medium text-teal-800">
|
||||
Building in Public
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roadmap */}
|
||||
<div className="space-y-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
4-Weken Roadmap
|
||||
</h2>
|
||||
<p className="text-slate-600">
|
||||
Volg de voortgang van dit experiment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{roadmapItems.map((week, idx) => (
|
||||
<div
|
||||
key={week.week}
|
||||
className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Week Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-10 w-10 rounded-lg flex items-center justify-center font-mono text-sm font-semibold ${
|
||||
week.status === "in-progress"
|
||||
? "bg-gradient-to-br from-amber-500 to-amber-600 text-white"
|
||||
: week.status === "upcoming"
|
||||
? "bg-slate-100 text-slate-400"
|
||||
: "bg-gradient-to-br from-teal-600 to-teal-700 text-white"
|
||||
}`}
|
||||
>
|
||||
W{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{week.week}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">{week.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={week.status} />
|
||||
</div>
|
||||
|
||||
{/* Items Checklist */}
|
||||
<ul className="space-y-2">
|
||||
{week.items.map((item, itemIdx) => (
|
||||
<li key={itemIdx} className="flex items-start gap-3">
|
||||
{item.done ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-teal-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-slate-300 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
item.done
|
||||
? "text-slate-700 line-through"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-slate-600 mb-4">
|
||||
Volg de build op LinkedIn voor real-time updates
|
||||
</p>
|
||||
<a
|
||||
href="https://linkedin.com/in/colinlit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
<span>Volg op LinkedIn</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = {
|
||||
"in-progress": {
|
||||
bg: "bg-amber-50 border-amber-200",
|
||||
text: "text-amber-700",
|
||||
label: "In Progress",
|
||||
icon: Clock,
|
||||
},
|
||||
upcoming: {
|
||||
bg: "bg-slate-50 border-slate-200",
|
||||
text: "text-slate-600",
|
||||
label: "Upcoming",
|
||||
icon: Circle,
|
||||
},
|
||||
completed: {
|
||||
bg: "bg-teal-50 border-teal-200",
|
||||
text: "text-teal-700",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
}
|
||||
|
||||
const { bg, text, label, icon: Icon } = config[status as keyof typeof config]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-xs font-medium ${bg} ${text}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Save, Loader2 } from 'lucide-react';
|
||||
import type { ClientFormData } from '@/lib/types/client';
|
||||
import type { Client } from '@/lib/types/client';
|
||||
import { createClient, updateClient } from '../actions';
|
||||
|
||||
interface ClientFormProps {
|
||||
client?: Client;
|
||||
}
|
||||
|
||||
export function ClientForm({ client }: ClientFormProps) {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<ClientFormData>({
|
||||
first_name: client?.first_name || '',
|
||||
last_name: client?.last_name || '',
|
||||
birth_date: client?.birth_date || '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (client) {
|
||||
await updateClient(client.id, formData);
|
||||
router.push(`/epd/clients/${client.id}`);
|
||||
} else {
|
||||
const newClient = await createClient(formData);
|
||||
router.push(`/epd/clients/${newClient.id}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Form submission error:', err);
|
||||
setError('Er is een fout opgetreden. Probeer het opnieuw.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof ClientFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="first_name"
|
||||
className="block text-sm font-medium text-slate-700 mb-2"
|
||||
>
|
||||
Voornaam <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
placeholder="bijv. Jan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="last_name"
|
||||
className="block text-sm font-medium text-slate-700 mb-2"
|
||||
>
|
||||
Achternaam <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
placeholder="bijv. de Vries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Birth Date */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="birth_date"
|
||||
className="block text-sm font-medium text-slate-700 mb-2"
|
||||
>
|
||||
Geboortedatum <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="birth_date"
|
||||
required
|
||||
value={formData.birth_date}
|
||||
onChange={(e) => handleChange('birth_date', e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Bezig...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{client ? 'Bijwerken' : 'Opslaan'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export function ClientListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar Skeleton */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="h-10 bg-slate-100 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Table Skeleton - Desktop */}
|
||||
<div className="hidden md:block bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-slate-100 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-slate-100 rounded w-1/4 animate-pulse" />
|
||||
<div className="h-3 bg-slate-100 rounded w-1/6 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
|
||||
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
|
||||
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Skeleton - Mobile */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-12 w-12 rounded-full bg-slate-100 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-slate-100 rounded w-3/4 animate-pulse" />
|
||||
<div className="h-3 bg-slate-100 rounded w-1/2 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="h-3 bg-slate-100 rounded animate-pulse" />
|
||||
<div className="h-3 bg-slate-100 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 h-10 bg-slate-100 rounded-lg animate-pulse" />
|
||||
<div className="flex-1 h-10 bg-slate-100 rounded-lg animate-pulse" />
|
||||
<div className="h-10 w-10 bg-slate-100 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, ArrowUpDown, Eye, Edit2, Trash2, Users } from 'lucide-react';
|
||||
import type { Client } from '@/lib/types/client';
|
||||
import { transformClient } from '@/lib/types/client';
|
||||
import { deleteClient } from '../actions';
|
||||
|
||||
interface ClientListProps {
|
||||
initialClients: Client[];
|
||||
}
|
||||
|
||||
export function ClientList({ initialClients }: ClientListProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||
|
||||
// Transform clients with computed fields
|
||||
const clients = initialClients.map(transformClient);
|
||||
|
||||
// Client-side filtering (for instant feedback)
|
||||
const filteredClients = clients.filter((client) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
client.first_name.toLowerCase().includes(query) ||
|
||||
client.last_name.toLowerCase().includes(query) ||
|
||||
client.full_name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
// Update URL with search param
|
||||
const params = new URLSearchParams();
|
||||
if (value) params.set('search', value);
|
||||
router.push(`/epd/clients?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Weet u zeker dat u ${name} wilt verwijderen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(id);
|
||||
try {
|
||||
await deleteClient(id);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Error deleting client:', error);
|
||||
alert('Fout bij verwijderen van cliënt');
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Zoek op naam..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredClients.length === 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
||||
<Users className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-1">
|
||||
{searchQuery ? 'Geen resultaten' : 'Geen cliënten'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
{searchQuery
|
||||
? 'Probeer een andere zoekopdracht'
|
||||
: 'Voeg uw eerste cliënt toe om te beginnen'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Link
|
||||
href="/epd/clients/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<span>Nieuwe cliënt toevoegen</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Table View */}
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="hidden md:block bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Naam
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Leeftijd
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Geboortedatum
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Toegevoegd
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Acties
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200">
|
||||
{filteredClients.map((client) => (
|
||||
<tr
|
||||
key={client.id}
|
||||
onClick={() => router.push(`/epd/clients/${client.id}`)}
|
||||
className="hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center">
|
||||
<span className="text-white font-medium text-sm">
|
||||
{client.first_name[0]}
|
||||
{client.last_name[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{client.full_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
|
||||
{client.age} jaar
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
|
||||
{new Date(client.birth_date).toLocaleDateString('nl-NL')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
|
||||
{new Date(client.created_at).toLocaleDateString('nl-NL')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link
|
||||
href={`/epd/clients/${client.id}`}
|
||||
className="text-teal-600 hover:text-teal-900 p-1 rounded hover:bg-teal-50 transition-colors"
|
||||
title="Bekijken"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/epd/clients/${client.id}/edit`}
|
||||
className="text-slate-600 hover:text-slate-900 p-1 rounded hover:bg-slate-50 transition-colors"
|
||||
title="Bewerken"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(client.id, client.full_name)}
|
||||
disabled={isDeleting === client.id}
|
||||
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
title="Verwijderen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Card View */}
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="md:hidden space-y-3">
|
||||
{filteredClients.map((client) => (
|
||||
<div
|
||||
key={client.id}
|
||||
className="bg-white rounded-lg border border-slate-200 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center">
|
||||
<span className="text-white font-medium">
|
||||
{client.first_name[0]}
|
||||
{client.last_name[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">
|
||||
{client.full_name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{client.age} jaar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-3 text-sm text-slate-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Geboortedatum:</span>
|
||||
<span>{new Date(client.birth_date).toLocaleDateString('nl-NL')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Toegevoegd:</span>
|
||||
<span>{new Date(client.created_at).toLocaleDateString('nl-NL')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-slate-200">
|
||||
<Link
|
||||
href={`/epd/clients/${client.id}`}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-teal-50 text-teal-700 font-medium rounded-lg hover:bg-teal-100 transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>Bekijken</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/epd/clients/${client.id}/edit`}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-slate-50 text-slate-700 font-medium rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
<span>Bewerken</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(client.id, client.full_name)}
|
||||
disabled={isDeleting === client.id}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Verwijderen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ClientForm } from '../components/client-form';
|
||||
|
||||
export default function NewClientPage() {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-2xl mx-auto">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href="/epd/clients"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-6 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Terug naar overzicht</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Nieuwe cliënt</h1>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Voeg een nieuwe cliënt toe aan het systeem
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ClientForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Clients Root Redirect
|
||||
* Redirects /epd/clients to /epd/patients for backward compatibility
|
||||
*/
|
||||
|
||||
export default function ClientsRedirect() {
|
||||
redirect('/epd/patients');
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Catch-all Redirect Route for /epd/clients/
|
||||
*
|
||||
* Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility
|
||||
* This ensures that old bookmarks and links continue to work after migration.
|
||||
*
|
||||
* Preserves query parameters (e.g., ?tab=intake&search=test)
|
||||
*/
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
// Build new path with query parameters
|
||||
const newPath = `/epd/patients/${path.join('/')}`;
|
||||
const newUrl = new URL(newPath, request.url);
|
||||
|
||||
// Preserve all query parameters
|
||||
searchParams.forEach((value, key) => {
|
||||
newUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
redirect(newUrl.toString());
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Clients Root Redirect
|
||||
* Redirects /epd/clients to /epd/patients for backward compatibility
|
||||
*/
|
||||
|
||||
export default function ClientsRedirect() {
|
||||
redirect('/epd/patients');
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Users,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
LayoutDashboard,
|
||||
User,
|
||||
ClipboardList,
|
||||
Stethoscope,
|
||||
Calendar,
|
||||
FileBarChart,
|
||||
PenLine,
|
||||
Zap
|
||||
@@ -48,7 +45,6 @@ interface EPDSidebarProps {
|
||||
// LEVEL 1: Behandelaar Context Navigation
|
||||
const level1NavigationItems: NavigationItem[] = [
|
||||
{ id: "cortex", name: "Cortex", icon: Zap, href: "/epd/cortex" },
|
||||
{ id: "dashboard", name: "Dashboard", icon: LayoutDashboard, href: "/epd/dashboard" },
|
||||
{ id: "clients", name: "Cliënten", icon: Users, href: "/epd/patients" },
|
||||
{
|
||||
id: "verpleegrapportage",
|
||||
@@ -61,7 +57,6 @@ const level1NavigationItems: NavigationItem[] = [
|
||||
]
|
||||
},
|
||||
{ id: "agenda", name: "Agenda", icon: FileText, href: "/epd/agenda" },
|
||||
{ id: "reports", name: "Rapportage", icon: Settings, href: "/epd/reports" },
|
||||
];
|
||||
|
||||
// LEVEL 2: Client Dossier Context Navigation (clientId gets injected)
|
||||
@@ -70,8 +65,6 @@ const level2NavigationItems: NavigationItem[] = [
|
||||
{ id: "basisgegevens", name: "Basisgegevens", icon: User, href: "/basisgegevens" },
|
||||
{ id: "screening", name: "Screening", icon: ClipboardList, href: "/screening" },
|
||||
{ id: "intake", name: "Intake", icon: FileText, href: "/intakes" },
|
||||
{ id: "diagnose", name: "Diagnose", icon: Stethoscope, href: "/diagnose" },
|
||||
{ id: "behandelplan", name: "Behandelplan", icon: Calendar, href: "/behandelplan" },
|
||||
{ id: "rapportage", name: "Rapportage", icon: FileBarChart, href: "/rapportage" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Behandelaar Dashboard - Level 1
|
||||
*
|
||||
* Overzicht van caseload, aandachtspunten, taken en recente activiteit.
|
||||
*/
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
Behandelaar Dashboard
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Caseload overzicht, aandachtspunten en aankomende afspraken
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Epic 1 - Placeholder for future content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import type { GeneratedPlan, SmartGoal, Intervention, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
|
||||
import { transformFromFlat } from '@/lib/types/behandelplan';
|
||||
import type { LifeDomainScore } from '@/lib/types/leefgebieden';
|
||||
import type { Json } from '@/lib/supabase/database.types';
|
||||
|
||||
/**
|
||||
* Get care plans for a patient
|
||||
*/
|
||||
export async function getCarePlans(patientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('care_plans')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching care plans:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest active care plan for a patient
|
||||
*/
|
||||
export async function getActiveCarePlan(patientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('care_plans')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.in('status', ['draft', 'active'])
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
console.error('Error fetching active care plan:', error);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get intakes for a patient (to select for plan generation)
|
||||
*/
|
||||
export async function getPatientIntakes(patientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('intakes')
|
||||
.select('id, title, status, start_date, life_domains, notes')
|
||||
.eq('patient_id', patientId)
|
||||
.order('start_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching intakes:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conditions for a patient
|
||||
*/
|
||||
export async function getPatientConditions(patientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conditions')
|
||||
.select('id, category, code_display, severity_code, severity_display, recorded_date')
|
||||
.eq('patient_id', patientId)
|
||||
.order('recorded_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching conditions:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new care plan from generated plan
|
||||
*/
|
||||
export async function createCarePlan(
|
||||
patientId: string,
|
||||
intakeId: string,
|
||||
generatedPlan: GeneratedPlan,
|
||||
title: string = 'Behandelplan'
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current version
|
||||
const { data: existing } = await supabase
|
||||
.from('care_plans')
|
||||
.select('version')
|
||||
.eq('patient_id', patientId)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const nextVersion = (existing?.version || 0) + 1;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('care_plans')
|
||||
.insert({
|
||||
patient_id: patientId,
|
||||
based_on_intake_id: intakeId,
|
||||
title: `${title} v${nextVersion}`,
|
||||
status: 'draft',
|
||||
intent: 'plan',
|
||||
version: nextVersion,
|
||||
goals: generatedPlan.doelen as unknown as Json,
|
||||
activities: generatedPlan.interventies as unknown as Json,
|
||||
behandelstructuur: generatedPlan.behandelstructuur as unknown as Json,
|
||||
evaluatiemomenten: generatedPlan.evaluatiemomenten as unknown as Json,
|
||||
sessie_planning: generatedPlan.sessiePlanning as unknown as Json,
|
||||
veiligheidsplan: (generatedPlan.veiligheidsplan || null) as unknown as Json,
|
||||
period_start: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating care plan:', error);
|
||||
throw new Error('Kon behandelplan niet opslaan');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update care plan status
|
||||
*/
|
||||
export async function updateCarePlanStatus(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
status: 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked'
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const updateData: Record<string, unknown> = { status };
|
||||
|
||||
// Set published_at when activating
|
||||
if (status === 'active') {
|
||||
updateData.published_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update(updateData)
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating care plan status:', error);
|
||||
throw new Error('Kon status niet bijwerken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update care plan goals
|
||||
*/
|
||||
export async function updateCarePlanGoals(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
goals: GeneratedPlan['doelen']
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ goals: goals as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating goals:', error);
|
||||
throw new Error('Kon doelen niet bijwerken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save life domains to intake
|
||||
*/
|
||||
export async function saveLifeDomains(
|
||||
intakeId: string,
|
||||
patientId: string,
|
||||
lifeDomains: LifeDomainScore[]
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('intakes')
|
||||
.update({ life_domains: lifeDomains as unknown as Json })
|
||||
.eq('id', intakeId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving life domains:', error);
|
||||
throw new Error('Kon leefgebieden niet opslaan');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a care plan
|
||||
*/
|
||||
export async function deleteCarePlan(carePlanId: string, patientId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.delete()
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting care plan:', error);
|
||||
throw new Error('Kon behandelplan niet verwijderen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty/manual care plan
|
||||
*/
|
||||
export async function createEmptyCarePlan(
|
||||
patientId: string,
|
||||
intakeId?: string,
|
||||
title: string = 'Behandelplan'
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current version
|
||||
const { data: existing } = await supabase
|
||||
.from('care_plans')
|
||||
.select('version')
|
||||
.eq('patient_id', patientId)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const nextVersion = (existing?.version || 0) + 1;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('care_plans')
|
||||
.insert({
|
||||
patient_id: patientId,
|
||||
based_on_intake_id: intakeId || null,
|
||||
title: `${title} v${nextVersion}`,
|
||||
status: 'draft',
|
||||
intent: 'plan',
|
||||
version: nextVersion,
|
||||
goals: [],
|
||||
activities: [],
|
||||
period_start: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating empty care plan:', error);
|
||||
throw new Error('Kon behandelplan niet aanmaken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BEHANDELSTRUCTUUR
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Update behandelstructuur
|
||||
*/
|
||||
export async function updateBehandelstructuur(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
behandelstructuur: Behandelstructuur
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ behandelstructuur: behandelstructuur as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating behandelstructuur:', error);
|
||||
throw new Error('Kon behandelstructuur niet bijwerken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GOALS (DOELEN)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add a goal to a care plan
|
||||
*/
|
||||
export async function addGoal(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
goal: SmartGoal
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current goals
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('goals')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
||||
const updatedGoals = [...currentGoals, goal];
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ goals: updatedGoals as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding goal:', error);
|
||||
throw new Error('Kon doel niet toevoegen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single goal in a care plan
|
||||
*/
|
||||
export async function updateGoal(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
goalId: string,
|
||||
updatedGoal: SmartGoal
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current goals
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('goals')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
||||
const updatedGoals = currentGoals.map((g) =>
|
||||
g.id === goalId ? updatedGoal : g
|
||||
);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ goals: updatedGoals as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating goal:', error);
|
||||
throw new Error('Kon doel niet bijwerken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a goal from a care plan
|
||||
*/
|
||||
export async function deleteGoal(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
goalId: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current goals
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('goals')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
||||
const updatedGoals = currentGoals.filter((g) => g.id !== goalId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ goals: updatedGoals as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting goal:', error);
|
||||
throw new Error('Kon doel niet verwijderen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERVENTIONS (INTERVENTIES)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add an intervention to a care plan
|
||||
*/
|
||||
export async function addIntervention(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
intervention: Intervention
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current interventions
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('activities')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
||||
const updatedInterventions = [...currentInterventions, intervention];
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ activities: updatedInterventions as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding intervention:', error);
|
||||
throw new Error('Kon interventie niet toevoegen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single intervention in a care plan
|
||||
*/
|
||||
export async function updateIntervention(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
interventionId: string,
|
||||
updatedIntervention: Intervention
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current interventions
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('activities')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
||||
const updatedInterventions = currentInterventions.map((i) =>
|
||||
i.id === interventionId ? updatedIntervention : i
|
||||
);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ activities: updatedInterventions as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating intervention:', error);
|
||||
throw new Error('Kon interventie niet bijwerken');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an intervention from a care plan
|
||||
*/
|
||||
export async function deleteIntervention(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
interventionId: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current interventions
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('activities')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentInterventions = (plan?.activities as unknown as Intervention[]) || [];
|
||||
const updatedInterventions = currentInterventions.filter((i) => i.id !== interventionId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({ activities: updatedInterventions as unknown as Json })
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting intervention:', error);
|
||||
throw new Error('Kon interventie niet verwijderen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BEHANDELDOEL (FLAT STRUCTURE - doel met embedded interventies)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Save a behandeldoel (creates or updates)
|
||||
* Transforms flat structure to goals + activities for backwards compatibility
|
||||
*/
|
||||
export async function saveBehandeldoel(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
behandeldoel: Behandeldoel
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current plan
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('goals, activities')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
||||
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
|
||||
|
||||
// Check if this is an update or a new goal
|
||||
const existingGoalIndex = currentGoals.findIndex((g) => g.id === behandeldoel.id);
|
||||
|
||||
// Convert behandeldoel to SmartGoal
|
||||
const smartGoal: SmartGoal = {
|
||||
id: behandeldoel.id,
|
||||
title: behandeldoel.title,
|
||||
description: '', // Not used in flat structure
|
||||
clientVersion: behandeldoel.clientVersion,
|
||||
lifeDomain: behandeldoel.lifeDomain,
|
||||
priority: 'middel', // Default
|
||||
measurability: '', // Not used in flat structure
|
||||
timelineWeeks: behandeldoel.endWeek,
|
||||
status: behandeldoel.status,
|
||||
progress: behandeldoel.progress,
|
||||
};
|
||||
|
||||
// Update goals array
|
||||
let updatedGoals: SmartGoal[];
|
||||
if (existingGoalIndex >= 0) {
|
||||
updatedGoals = currentGoals.map((g, i) =>
|
||||
i === existingGoalIndex ? smartGoal : g
|
||||
);
|
||||
} else {
|
||||
updatedGoals = [...currentGoals, smartGoal];
|
||||
}
|
||||
|
||||
// Handle interventions: remove old ones for this goal and add new ones
|
||||
const otherActivities = currentActivities.filter(
|
||||
(a) => !a.linkedGoalIds.includes(behandeldoel.id)
|
||||
);
|
||||
|
||||
const newActivities: Intervention[] = behandeldoel.interventies.map((int) => ({
|
||||
id: int.id,
|
||||
name: int.name,
|
||||
description: int.description,
|
||||
rationale: '', // Not used in flat structure
|
||||
linkedGoalIds: [behandeldoel.id],
|
||||
}));
|
||||
|
||||
const updatedActivities = [...otherActivities, ...newActivities];
|
||||
|
||||
// Save to database
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({
|
||||
goals: updatedGoals as unknown as Json,
|
||||
activities: updatedActivities as unknown as Json,
|
||||
})
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving behandeldoel:', error);
|
||||
throw new Error('Kon behandeldoel niet opslaan');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a behandeldoel and its linked interventions
|
||||
*/
|
||||
export async function deleteBehandeldoel(
|
||||
carePlanId: string,
|
||||
patientId: string,
|
||||
doelId: string
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get current plan
|
||||
const { data: plan } = await supabase
|
||||
.from('care_plans')
|
||||
.select('goals, activities')
|
||||
.eq('id', carePlanId)
|
||||
.single();
|
||||
|
||||
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
|
||||
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
|
||||
|
||||
// Remove the goal
|
||||
const updatedGoals = currentGoals.filter((g) => g.id !== doelId);
|
||||
|
||||
// Remove interventions linked to this goal
|
||||
const updatedActivities = currentActivities.filter(
|
||||
(a) => !a.linkedGoalIds.includes(doelId)
|
||||
);
|
||||
|
||||
// Save to database
|
||||
const { error } = await supabase
|
||||
.from('care_plans')
|
||||
.update({
|
||||
goals: updatedGoals as unknown as Json,
|
||||
activities: updatedActivities as unknown as Json,
|
||||
})
|
||||
.eq('id', carePlanId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting behandeldoel:', error);
|
||||
throw new Error('Kon behandeldoel niet verwijderen');
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BehandelplanView, BehandelplanList } from '@/components/behandelplan';
|
||||
import { BehandelplanFlat } from '@/components/behandelplan/flat';
|
||||
import {
|
||||
createCarePlan,
|
||||
updateCarePlanStatus,
|
||||
createEmptyCarePlan,
|
||||
updateBehandelstructuur,
|
||||
addGoal,
|
||||
updateGoal,
|
||||
deleteGoal,
|
||||
addIntervention,
|
||||
updateIntervention,
|
||||
deleteIntervention,
|
||||
saveBehandeldoel,
|
||||
deleteBehandeldoel,
|
||||
} from './actions';
|
||||
import type { GeneratedPlan, SmartGoal, Intervention, Sessie, Evaluatiemoment, Veiligheidsplan, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
|
||||
import type { LifeDomainScore } from '@/lib/types/leefgebieden';
|
||||
import type { Json } from '@/lib/supabase/database.types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
|
||||
// Database row types (what we get from Supabase)
|
||||
interface DbCarePlan {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
version: number | null;
|
||||
goals: Json | null;
|
||||
activities: Json | null;
|
||||
behandelstructuur: Json | null;
|
||||
sessie_planning: Json | null;
|
||||
evaluatiemomenten: Json | null;
|
||||
veiligheidsplan: Json | null;
|
||||
created_at: string | null;
|
||||
published_at: string | null;
|
||||
period_start: string | null;
|
||||
}
|
||||
|
||||
interface DbIntake {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
life_domains: Json | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface DbCondition {
|
||||
id: string;
|
||||
category: string;
|
||||
code_display: string;
|
||||
severity_code: string | null;
|
||||
severity_display: string | null;
|
||||
}
|
||||
|
||||
// Mapped types for the view component
|
||||
interface ViewCarePlan {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked' | 'entered-in-error' | 'unknown';
|
||||
version: number | null;
|
||||
goals: SmartGoal[] | null;
|
||||
activities: Intervention[] | null;
|
||||
behandelstructuur: GeneratedPlan['behandelstructuur'] | null;
|
||||
sessie_planning: Sessie[] | null;
|
||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
||||
veiligheidsplan: Veiligheidsplan | null;
|
||||
created_at: string | null;
|
||||
published_at: string | null;
|
||||
period_start: string | null;
|
||||
}
|
||||
|
||||
interface ViewIntake {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
life_domains: LifeDomainScore[] | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface ViewCondition {
|
||||
id: string;
|
||||
category: string;
|
||||
code_display: string;
|
||||
severity_code: string | null;
|
||||
severity_display: string | null;
|
||||
}
|
||||
|
||||
interface BehandelplanPageClientProps {
|
||||
patientId: string;
|
||||
allPlans: DbCarePlan[];
|
||||
intakes: DbIntake[];
|
||||
conditions: DbCondition[];
|
||||
}
|
||||
|
||||
// Helper to map database types to view types
|
||||
function mapCarePlan(dbPlan: DbCarePlan | null): ViewCarePlan | null {
|
||||
if (!dbPlan) return null;
|
||||
return {
|
||||
...dbPlan,
|
||||
status: dbPlan.status as ViewCarePlan['status'],
|
||||
goals: dbPlan.goals as SmartGoal[] | null,
|
||||
activities: dbPlan.activities as Intervention[] | null,
|
||||
behandelstructuur: dbPlan.behandelstructuur as GeneratedPlan['behandelstructuur'] | null,
|
||||
sessie_planning: dbPlan.sessie_planning as Sessie[] | null,
|
||||
evaluatiemomenten: dbPlan.evaluatiemomenten as Evaluatiemoment[] | null,
|
||||
veiligheidsplan: dbPlan.veiligheidsplan as Veiligheidsplan | null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapIntakes(dbIntakes: DbIntake[]): ViewIntake[] {
|
||||
return dbIntakes.map((intake) => ({
|
||||
...intake,
|
||||
life_domains: intake.life_domains as LifeDomainScore[] | null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function BehandelplanPageClient({
|
||||
patientId,
|
||||
allPlans: initialPlans,
|
||||
intakes,
|
||||
conditions,
|
||||
}: BehandelplanPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// View mode toggle: 'flat' = nieuwe platte UI, 'detailed' = oude gedetailleerde UI
|
||||
const [viewMode, setViewMode] = useState<'flat' | 'detailed'>('flat');
|
||||
|
||||
// State voor alle plannen en selectie
|
||||
const [plans, setPlans] = useState<DbCarePlan[]>(initialPlans);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
|
||||
// Selecteer standaard het nieuwste actieve/draft plan, of het eerste plan
|
||||
initialPlans.find(p => p.status === 'active')?.id ||
|
||||
initialPlans.find(p => p.status === 'draft')?.id ||
|
||||
initialPlans[0]?.id ||
|
||||
null
|
||||
);
|
||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
||||
const [showCreateView, setShowCreateView] = useState(false);
|
||||
|
||||
// Geselecteerd plan
|
||||
const selectedPlan = useMemo(() => {
|
||||
const plan = plans.find(p => p.id === selectedPlanId);
|
||||
return plan ? mapCarePlan(plan) : null;
|
||||
}, [plans, selectedPlanId]);
|
||||
|
||||
// Handler voor plan selectie
|
||||
const handleSelectPlan = useCallback((planId: string) => {
|
||||
setSelectedPlanId(planId);
|
||||
setShowCreateView(false);
|
||||
}, []);
|
||||
|
||||
// Handler voor nieuw plan aanmaken (toont create view)
|
||||
const handleShowCreateView = useCallback(() => {
|
||||
setSelectedPlanId(null);
|
||||
setShowCreateView(true);
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (intakeId: string) => {
|
||||
// Get the first condition if available
|
||||
const conditionId = conditions[0]?.id;
|
||||
|
||||
// Call the generate API
|
||||
const response = await fetch('/api/behandelplan/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
patientId,
|
||||
intakeId,
|
||||
conditionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Er ging iets mis bij het genereren');
|
||||
}
|
||||
|
||||
const generatedPlan: GeneratedPlan = await response.json();
|
||||
|
||||
// Save to database via server action
|
||||
const savedPlan = await createCarePlan(patientId, intakeId, generatedPlan);
|
||||
|
||||
// Update plans list en selecteer het nieuwe plan
|
||||
const newPlan = savedPlan as DbCarePlan;
|
||||
setPlans(prev => [newPlan, ...prev]);
|
||||
setSelectedPlanId(newPlan.id);
|
||||
setShowCreateView(false);
|
||||
|
||||
// Revalidate the page
|
||||
router.refresh();
|
||||
},
|
||||
[patientId, conditions, router]
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: ViewCarePlan['status']) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
// Only allow valid status transitions
|
||||
if (!['draft', 'active', 'on-hold', 'completed', 'revoked'].includes(status)) {
|
||||
throw new Error('Ongeldige status');
|
||||
}
|
||||
|
||||
await updateCarePlanStatus(
|
||||
selectedPlan.id,
|
||||
patientId,
|
||||
status as 'draft' | 'active' | 'on-hold' | 'completed' | 'revoked'
|
||||
);
|
||||
|
||||
// Update plans list
|
||||
setPlans(prev => prev.map(p =>
|
||||
p.id === selectedPlan.id
|
||||
? {
|
||||
...p,
|
||||
status,
|
||||
published_at: status === 'active' ? new Date().toISOString() : p.published_at
|
||||
}
|
||||
: p
|
||||
));
|
||||
|
||||
// Revalidate
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleCreateManual = useCallback(
|
||||
async (intakeId?: string) => {
|
||||
setIsCreatingNew(true);
|
||||
try {
|
||||
// Create empty care plan via server action
|
||||
const savedPlan = await createEmptyCarePlan(patientId, intakeId);
|
||||
|
||||
// Update plans list en selecteer het nieuwe plan
|
||||
const newPlan = savedPlan as DbCarePlan;
|
||||
setPlans(prev => [newPlan, ...prev]);
|
||||
setSelectedPlanId(newPlan.id);
|
||||
setShowCreateView(false);
|
||||
|
||||
// Revalidate the page
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsCreatingNew(false);
|
||||
}
|
||||
},
|
||||
[patientId, router]
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// EDIT HANDLERS
|
||||
// =============================================================================
|
||||
|
||||
const handleUpdateBehandelstructuur = useCallback(
|
||||
async (data: Behandelstructuur) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await updateBehandelstructuur(selectedPlan.id, patientId, data);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p =>
|
||||
p.id === selectedPlan.id
|
||||
? { ...p, behandelstructuur: data as unknown as Json }
|
||||
: p
|
||||
));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleAddGoal = useCallback(
|
||||
async (goal: SmartGoal) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await addGoal(selectedPlan.id, patientId, goal);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
||||
return { ...p, goals: [...currentGoals, goal] as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleUpdateGoal = useCallback(
|
||||
async (goalId: string, updatedGoal: SmartGoal) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await updateGoal(selectedPlan.id, patientId, goalId, updatedGoal);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
||||
const newGoals = currentGoals.map(g => g.id === goalId ? updatedGoal : g);
|
||||
return { ...p, goals: newGoals as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleDeleteGoal = useCallback(
|
||||
async (goalId: string) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await deleteGoal(selectedPlan.id, patientId, goalId);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentGoals = (p.goals as unknown as SmartGoal[]) || [];
|
||||
const newGoals = currentGoals.filter(g => g.id !== goalId);
|
||||
return { ...p, goals: newGoals as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleAddIntervention = useCallback(
|
||||
async (intervention: Intervention) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await addIntervention(selectedPlan.id, patientId, intervention);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
||||
return { ...p, activities: [...currentInterventions, intervention] as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleUpdateIntervention = useCallback(
|
||||
async (interventionId: string, updatedIntervention: Intervention) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await updateIntervention(selectedPlan.id, patientId, interventionId, updatedIntervention);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
||||
const newInterventions = currentInterventions.map(i => i.id === interventionId ? updatedIntervention : i);
|
||||
return { ...p, activities: newInterventions as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleDeleteIntervention = useCallback(
|
||||
async (interventionId: string) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await deleteIntervention(selectedPlan.id, patientId, interventionId);
|
||||
|
||||
// Update local state
|
||||
setPlans(prev => prev.map(p => {
|
||||
if (p.id !== selectedPlan.id) return p;
|
||||
const currentInterventions = (p.activities as unknown as Intervention[]) || [];
|
||||
const newInterventions = currentInterventions.filter(i => i.id !== interventionId);
|
||||
return { ...p, activities: newInterventions as unknown as Json };
|
||||
}));
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// FLAT VIEW HANDLERS (Behandeldoel met embedded interventies)
|
||||
// =============================================================================
|
||||
|
||||
const handleSaveBehandeldoel = useCallback(
|
||||
async (doel: Behandeldoel) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await saveBehandeldoel(selectedPlan.id, patientId, doel);
|
||||
|
||||
// Update local state - we need to update both goals and activities
|
||||
// For now, just refresh the page to get fresh data
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
const handleDeleteBehandeldoel = useCallback(
|
||||
async (doelId: string) => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
await deleteBehandeldoel(selectedPlan.id, patientId, doelId);
|
||||
router.refresh();
|
||||
},
|
||||
[selectedPlan, patientId, router]
|
||||
);
|
||||
|
||||
// Get hulpvraag from first intake notes (first line/sentence)
|
||||
const hulpvraag = useMemo(() => {
|
||||
const firstIntake = intakes[0];
|
||||
if (!firstIntake?.notes) return null;
|
||||
// Get first sentence or first 150 chars
|
||||
const notes = firstIntake.notes;
|
||||
const firstSentence = notes.split(/[.!?]/)[0];
|
||||
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
|
||||
}, [intakes]);
|
||||
|
||||
// Get life domain scores from first intake
|
||||
const lifeDomainScores = useMemo(() => {
|
||||
const firstIntake = intakes[0];
|
||||
return firstIntake?.life_domains as LifeDomainScore[] | null;
|
||||
}, [intakes]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header met view toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<BehandelplanList
|
||||
plans={plans.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
version: p.version,
|
||||
created_at: p.created_at,
|
||||
published_at: p.published_at,
|
||||
}))}
|
||||
selectedPlanId={showCreateView ? null : selectedPlanId}
|
||||
onSelectPlan={handleSelectPlan}
|
||||
onCreateNew={handleShowCreateView}
|
||||
isCreating={isCreatingNew}
|
||||
/>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center gap-1 border rounded-lg p-1 bg-slate-50">
|
||||
<Button
|
||||
variant={viewMode === 'flat' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('flat')}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 mr-1.5" />
|
||||
Compact
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<List className="h-4 w-4 mr-1.5" />
|
||||
Uitgebreid
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geselecteerd plan - Flat view */}
|
||||
{viewMode === 'flat' && (
|
||||
<BehandelplanFlat
|
||||
patientId={patientId}
|
||||
carePlan={showCreateView ? null : selectedPlan}
|
||||
condition={conditions[0] || null}
|
||||
hulpvraag={hulpvraag}
|
||||
lifeDomainScores={lifeDomainScores}
|
||||
onGenerate={async () => {
|
||||
const intakeId = intakes[0]?.id;
|
||||
if (intakeId) await handleGenerate(intakeId);
|
||||
}}
|
||||
onCreateManual={async () => {
|
||||
await handleCreateManual(intakes[0]?.id);
|
||||
}}
|
||||
onStatusChange={handleStatusChange}
|
||||
onSaveBehandeldoel={handleSaveBehandeldoel}
|
||||
onDeleteBehandeldoel={handleDeleteBehandeldoel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Geselecteerd plan - Detailed view (oude UI) */}
|
||||
{viewMode === 'detailed' && (
|
||||
<BehandelplanView
|
||||
patientId={patientId}
|
||||
carePlan={showCreateView ? null : selectedPlan}
|
||||
intakes={mapIntakes(intakes)}
|
||||
conditions={conditions}
|
||||
onGenerate={handleGenerate}
|
||||
onStatusChange={handleStatusChange}
|
||||
onCreateManual={handleCreateManual}
|
||||
onUpdateBehandelstructuur={handleUpdateBehandelstructuur}
|
||||
onAddGoal={handleAddGoal}
|
||||
onUpdateGoal={handleUpdateGoal}
|
||||
onDeleteGoal={handleDeleteGoal}
|
||||
onAddIntervention={handleAddIntervention}
|
||||
onUpdateIntervention={handleUpdateIntervention}
|
||||
onDeleteIntervention={handleDeleteIntervention}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Behandelplan Page
|
||||
* E3.S1: Server component met data loading
|
||||
*/
|
||||
|
||||
import { getCarePlans, getPatientIntakes, getPatientConditions } from './actions';
|
||||
import { BehandelplanPageClient } from './page-client';
|
||||
|
||||
export default async function BehandelplanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Parallel data loading - haal ALLE plannen op
|
||||
const [allPlans, intakes, conditions] = await Promise.all([
|
||||
getCarePlans(id),
|
||||
getPatientIntakes(id),
|
||||
getPatientConditions(id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<BehandelplanPageClient
|
||||
patientId={id}
|
||||
allPlans={allPlans}
|
||||
intakes={intakes}
|
||||
conditions={conditions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import type { Database } from '@/lib/supabase/database.types';
|
||||
|
||||
export type Condition = Database['public']['Tables']['conditions']['Row'];
|
||||
type ClinicalStatus = 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved';
|
||||
|
||||
export type IntakeInfo = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
department: string | null;
|
||||
start_date: string | null;
|
||||
};
|
||||
|
||||
export type DiagnosisWithIntake = Condition & {
|
||||
intake?: IntakeInfo | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Haal alle diagnoses op voor een patiënt (uit alle intakes)
|
||||
*/
|
||||
export async function getPatientDiagnoses(patientId: string): Promise<DiagnosisWithIntake[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Haal eerst alle diagnoses op
|
||||
const { data: conditions, error: conditionsError } = await supabase
|
||||
.from('conditions')
|
||||
.select('*')
|
||||
.eq('patient_id', patientId)
|
||||
.order('recorded_date', { ascending: false });
|
||||
|
||||
if (conditionsError) {
|
||||
console.error('getPatientDiagnoses error', conditionsError);
|
||||
throw new Error('Kon diagnoses niet ophalen');
|
||||
}
|
||||
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Haal de intake IDs op
|
||||
const intakeIds = [...new Set(conditions.map((c) => c.encounter_id).filter((id): id is string => id !== null))];
|
||||
|
||||
if (intakeIds.length === 0) {
|
||||
return conditions.map((c) => ({ ...c, intake: null }));
|
||||
}
|
||||
|
||||
// Haal intake informatie op
|
||||
const { data: intakes, error: intakesError } = await supabase
|
||||
.from('intakes')
|
||||
.select('id, title, department, start_date')
|
||||
.in('id', intakeIds);
|
||||
|
||||
if (intakesError) {
|
||||
console.error('getPatientDiagnoses intakes error', intakesError);
|
||||
// Return conditions zonder intake info als de query faalt
|
||||
return conditions.map((c) => ({ ...c, intake: null }));
|
||||
}
|
||||
|
||||
// Maak lookup map
|
||||
const intakeMap = new Map<string, IntakeInfo>();
|
||||
intakes?.forEach((intake) => {
|
||||
intakeMap.set(intake.id, intake);
|
||||
});
|
||||
|
||||
// Combineer data
|
||||
return conditions.map((condition) => ({
|
||||
...condition,
|
||||
intake: condition.encounter_id ? intakeMap.get(condition.encounter_id) || null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Haal alle intakes op voor een patiënt (voor intake selectie dropdown)
|
||||
*/
|
||||
export async function getPatientIntakes(patientId: string) {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('intakes')
|
||||
.select('id, title, department, start_date')
|
||||
.eq('patient_id', patientId)
|
||||
.order('start_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('getPatientIntakes error', error);
|
||||
throw new Error('Kon intakes niet ophalen');
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ---------------- CRUD Actions ----------------
|
||||
|
||||
export interface CreateDiagnosisPayload {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
code: string;
|
||||
description: string;
|
||||
severity?: string;
|
||||
status?: ClinicalStatus;
|
||||
notes?: string;
|
||||
diagnosisType?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export async function createPatientDiagnosis(payload: CreateDiagnosisPayload) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const insertData = {
|
||||
patient_id: payload.patientId,
|
||||
encounter_id: payload.intakeId,
|
||||
code_code: payload.code,
|
||||
code_display: payload.description,
|
||||
code_system: 'ICD-10',
|
||||
clinical_status: payload.status || 'active',
|
||||
severity_display: payload.severity || null,
|
||||
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
|
||||
note: payload.notes || null,
|
||||
recorded_date: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log('createPatientDiagnosis payload:', JSON.stringify(insertData, null, 2));
|
||||
|
||||
const { error } = await supabase.from('conditions').insert(insertData);
|
||||
|
||||
if (error) {
|
||||
console.error('createPatientDiagnosis error:', error.message, error.details, error.hint);
|
||||
throw new Error(`Diagnose opslaan mislukt: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${payload.patientId}/diagnose`);
|
||||
}
|
||||
|
||||
export interface UpdateDiagnosisPayload {
|
||||
code?: string;
|
||||
description?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
diagnosisType?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export async function updatePatientDiagnosis(
|
||||
patientId: string,
|
||||
diagnosisId: string,
|
||||
payload: UpdateDiagnosisPayload
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (payload.code !== undefined) {
|
||||
updateData.code_code = payload.code;
|
||||
}
|
||||
if (payload.description !== undefined) {
|
||||
updateData.code_display = payload.description;
|
||||
}
|
||||
if (payload.status !== undefined) {
|
||||
updateData.clinical_status = payload.status;
|
||||
}
|
||||
if (payload.severity !== undefined) {
|
||||
updateData.severity_display = payload.severity;
|
||||
}
|
||||
if (payload.notes !== undefined) {
|
||||
updateData.note = payload.notes;
|
||||
}
|
||||
if (payload.diagnosisType !== undefined) {
|
||||
updateData.category = payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis';
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('conditions')
|
||||
.update(updateData)
|
||||
.eq('id', diagnosisId);
|
||||
|
||||
if (error) {
|
||||
console.error('updatePatientDiagnosis error', error);
|
||||
return { success: false, error: 'Diagnose bijwerken mislukt' };
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/diagnose`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deletePatientDiagnosis(
|
||||
patientId: string,
|
||||
diagnosisId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId);
|
||||
|
||||
if (error) {
|
||||
console.error('deletePatientDiagnosis error', error);
|
||||
return { success: false, error: 'Diagnose verwijderen mislukt' };
|
||||
}
|
||||
|
||||
revalidatePath(`/epd/patients/${patientId}/diagnose`);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Stethoscope, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { ICD10Combobox } from '@/app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/icd10-combobox';
|
||||
import {
|
||||
diagnosisSchema,
|
||||
diagnosisDefaults,
|
||||
type DiagnosisFormData,
|
||||
DIAGNOSIS_SEVERITIES,
|
||||
DIAGNOSIS_TYPES,
|
||||
DIAGNOSIS_STATUSES,
|
||||
} from '@/lib/schemas/diagnosis';
|
||||
import {
|
||||
createPatientDiagnosis,
|
||||
updatePatientDiagnosis,
|
||||
deletePatientDiagnosis,
|
||||
type DiagnosisWithIntake,
|
||||
type IntakeInfo,
|
||||
} from '../actions';
|
||||
import type { ICD10Code } from '@/lib/types/icd10';
|
||||
|
||||
interface DiagnosisDetailFormProps {
|
||||
patientId: string;
|
||||
intakes: IntakeInfo[];
|
||||
diagnosis: DiagnosisWithIntake | null;
|
||||
isNew: boolean;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Actief',
|
||||
remission: 'In remissie',
|
||||
resolved: 'Opgelost',
|
||||
inactive: 'Inactief',
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
licht: 'Licht',
|
||||
matig: 'Matig',
|
||||
ernstig: 'Ernstig',
|
||||
};
|
||||
|
||||
const DIAGNOSIS_TYPE_LABELS: Record<string, string> = {
|
||||
primary: 'Hoofddiagnose',
|
||||
secondary: 'Nevendiagnose',
|
||||
};
|
||||
|
||||
export function DiagnosisDetailForm({
|
||||
patientId,
|
||||
intakes,
|
||||
diagnosis,
|
||||
isNew,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: DiagnosisDetailFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [selectedICD10Code, setSelectedICD10Code] = useState<ICD10Code | null>(null);
|
||||
const [selectedIntakeId, setSelectedIntakeId] = useState<string>(
|
||||
diagnosis?.encounter_id || intakes[0]?.id || ''
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<DiagnosisFormData>({
|
||||
resolver: zodResolver(diagnosisSchema),
|
||||
defaultValues: diagnosisDefaults,
|
||||
});
|
||||
|
||||
const diagnosisType = watch('diagnosisType');
|
||||
const severity = watch('severity');
|
||||
const status = watch('status');
|
||||
|
||||
// Reset form wanneer diagnosis wijzigt
|
||||
useEffect(() => {
|
||||
if (diagnosis) {
|
||||
setValue('code', diagnosis.code_code || '');
|
||||
setValue('description', diagnosis.code_display || '');
|
||||
setValue('severity', (diagnosis.severity_display as 'licht' | 'matig' | 'ernstig') || 'matig');
|
||||
setValue('status', (diagnosis.clinical_status as 'active' | 'remission' | 'resolved' | 'inactive') || 'active');
|
||||
setValue('diagnosisType', diagnosis.category === 'primary-diagnosis' ? 'primary' : 'secondary');
|
||||
setValue('notes', diagnosis.note || '');
|
||||
|
||||
if (diagnosis.code_code && diagnosis.code_display) {
|
||||
setSelectedICD10Code({
|
||||
code: diagnosis.code_code,
|
||||
display: diagnosis.code_display,
|
||||
keywords: [],
|
||||
});
|
||||
}
|
||||
setSelectedIntakeId(diagnosis.encounter_id || intakes[0]?.id || '');
|
||||
} else {
|
||||
reset(diagnosisDefaults);
|
||||
setSelectedICD10Code(null);
|
||||
setSelectedIntakeId(intakes[0]?.id || '');
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
}, [diagnosis, reset, setValue, intakes]);
|
||||
|
||||
const handleICD10Select = (code: ICD10Code) => {
|
||||
setSelectedICD10Code(code);
|
||||
setValue('code', code.code);
|
||||
setValue('description', code.display);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: DiagnosisFormData) => {
|
||||
if (!selectedIntakeId) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Intake vereist',
|
||||
description: 'Selecteer een intake om de diagnose aan te koppelen.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await createPatientDiagnosis({
|
||||
patientId,
|
||||
intakeId: selectedIntakeId,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
status: data.status,
|
||||
notes: data.notes || undefined,
|
||||
diagnosisType: data.diagnosisType,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Diagnose toegevoegd',
|
||||
description: `${data.code} — ${data.description}`,
|
||||
});
|
||||
} else if (diagnosis) {
|
||||
const result = await updatePatientDiagnosis(patientId, diagnosis.id, {
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
status: data.status,
|
||||
notes: data.notes || undefined,
|
||||
diagnosisType: data.diagnosisType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Bijwerken mislukt',
|
||||
description: result.error || 'Er ging iets mis.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Diagnose bijgewerkt',
|
||||
description: `${data.code} — ${data.description}`,
|
||||
});
|
||||
}
|
||||
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Opslaan mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis.',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!diagnosis) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const result = await deletePatientDiagnosis(patientId, diagnosis.id);
|
||||
|
||||
if (!result.success) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Verwijderen mislukt',
|
||||
description: result.error || 'Er ging iets mis.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Diagnose verwijderd',
|
||||
description: `${diagnosis.code_code} is verwijderd.`,
|
||||
});
|
||||
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Verwijderen mislukt',
|
||||
description: error instanceof Error ? error.message : 'Er ging iets mis.',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty state
|
||||
if (!isNew && !diagnosis) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
||||
<Stethoscope className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-900 mb-1">Geen diagnose geselecteerd</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Selecteer een diagnose uit de lijst of voeg een nieuwe toe.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedIntake = intakes.find((i) => i.id === selectedIntakeId);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="border-b border-slate-200 pb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{isNew ? 'Nieuwe diagnose' : 'Diagnose bewerken'}
|
||||
</h3>
|
||||
{!isNew && diagnosis && (
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Gekoppeld aan: {diagnosis.intake?.title || diagnosis.intake?.department || 'Intake'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intake selectie (alleen bij nieuwe diagnose) */}
|
||||
{isNew && intakes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Intake <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedIntakeId}
|
||||
onValueChange={setSelectedIntakeId}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer intake" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{intakes.map((intake) => (
|
||||
<SelectItem key={intake.id} value={intake.id}>
|
||||
{intake.title || intake.department || 'Intake'}{' '}
|
||||
{intake.start_date && `(${new Date(intake.start_date).toLocaleDateString('nl-NL')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ICD-10 Code */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
ICD-10 Code <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<ICD10Combobox
|
||||
value={selectedICD10Code?.code || ''}
|
||||
onSelect={handleICD10Select}
|
||||
placeholder="Zoek op code of beschrijving..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{errors.code && <p className="text-sm text-red-600">{errors.code.message}</p>}
|
||||
{errors.description && <p className="text-sm text-red-600">{errors.description.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Ernst en Type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Ernst <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={severity}
|
||||
onValueChange={(value) => setValue('severity', value as 'licht' | 'matig' | 'ernstig')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIAGNOSIS_SEVERITIES.map((sev) => (
|
||||
<SelectItem key={sev} value={sev}>
|
||||
{SEVERITY_LABELS[sev]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.severity && <p className="text-sm text-red-600">{errors.severity.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4 pt-2">
|
||||
{DIAGNOSIS_TYPES.map((type) => (
|
||||
<label key={type} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value={type}
|
||||
checked={diagnosisType === type}
|
||||
onChange={(e) => setValue('diagnosisType', e.target.value as 'primary' | 'secondary')}
|
||||
disabled={isSubmitting}
|
||||
className="h-4 w-4 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
<span className="text-sm">{DIAGNOSIS_TYPE_LABELS[type]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.diagnosisType && <p className="text-sm text-red-600">{errors.diagnosisType.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Status <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'remission' | 'resolved' | 'inactive')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIAGNOSIS_STATUSES.map((stat) => (
|
||||
<SelectItem key={stat} value={stat}>
|
||||
{STATUS_LABELS[stat]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.status && <p className="text-sm text-red-600">{errors.status.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Onderbouwing */}
|
||||
<div className="space-y-2">
|
||||
<Label>Onderbouwing (optioneel)</Label>
|
||||
<Textarea
|
||||
{...register('notes')}
|
||||
placeholder="Klinische redenering..."
|
||||
rows={4}
|
||||
disabled={isSubmitting}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">{watch('notes')?.length || 0} / 500 karakters</p>
|
||||
{errors.notes && <p className="text-sm text-red-600">{errors.notes.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-200">
|
||||
{!isNew && diagnosis && !showDeleteConfirm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Verwijderen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600">Zeker weten?</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ja, verwijder'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isNew || !showDeleteConfirm) && (
|
||||
<Button type="submit" disabled={isSubmitting} className={isNew ? 'ml-auto' : ''}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Opslaan...' : 'Opslaan'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DiagnosisWithIntake } from '../actions';
|
||||
|
||||
interface DiagnosisListItemProps {
|
||||
diagnosis: DiagnosisWithIntake;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
active: { label: 'Actief', className: 'bg-green-100 text-green-700 border-green-300' },
|
||||
remission: { label: 'Remissie', className: 'bg-blue-100 text-blue-700 border-blue-300' },
|
||||
resolved: { label: 'Opgelost', className: 'bg-slate-100 text-slate-700 border-slate-300' },
|
||||
inactive: { label: 'Inactief', className: 'bg-amber-100 text-amber-700 border-amber-300' },
|
||||
};
|
||||
|
||||
export function DiagnosisListItem({ diagnosis, isSelected, onClick }: DiagnosisListItemProps) {
|
||||
const code = diagnosis.code_code || '';
|
||||
const description = diagnosis.code_display || '';
|
||||
const status = diagnosis.clinical_status || 'active';
|
||||
const isPrimary = diagnosis.category === 'primary-diagnosis';
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg border transition-all',
|
||||
'hover:border-teal-300 hover:bg-teal-50/50',
|
||||
isSelected
|
||||
? 'border-teal-500 bg-teal-50 ring-1 ring-teal-500'
|
||||
: 'border-slate-200 bg-white'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-slate-900 truncate">
|
||||
<span className="font-mono text-sm">{code}</span>
|
||||
{description && (
|
||||
<span className="ml-1.5 text-slate-700 font-normal">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', statusConfig.className)}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
{isPrimary && (
|
||||
<Badge className="bg-green-600 text-white text-xs hover:bg-green-600">
|
||||
HOOFD
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { Plus, Stethoscope } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DiagnosisListItem } from './diagnosis-list-item';
|
||||
import { DiagnosisDetailForm } from './diagnosis-detail-form';
|
||||
import type { DiagnosisWithIntake, IntakeInfo } from '../actions';
|
||||
|
||||
interface DiagnosisMasterDetailProps {
|
||||
patientId: string;
|
||||
diagnoses: DiagnosisWithIntake[];
|
||||
intakes: IntakeInfo[];
|
||||
}
|
||||
|
||||
export function DiagnosisMasterDetail({
|
||||
patientId,
|
||||
diagnoses,
|
||||
intakes,
|
||||
}: DiagnosisMasterDetailProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const selectedId = searchParams.get('selected');
|
||||
const isNew = selectedId === 'new';
|
||||
const selectedDiagnosis = selectedId && !isNew
|
||||
? diagnoses.find((d) => d.id === selectedId) || null
|
||||
: null;
|
||||
|
||||
const updateSelection = (id: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (id) {
|
||||
params.set('selected', id);
|
||||
} else {
|
||||
params.delete('selected');
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleNewDiagnosis = () => {
|
||||
updateSelection('new');
|
||||
};
|
||||
|
||||
const handleSelectDiagnosis = (id: string) => {
|
||||
updateSelection(id);
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
// Na opslaan blijven we op dezelfde selectie (of clear bij new)
|
||||
if (isNew) {
|
||||
updateSelection(null);
|
||||
}
|
||||
// Router refresh gebeurt automatisch door revalidatePath
|
||||
};
|
||||
|
||||
const handleDeleted = () => {
|
||||
updateSelection(null);
|
||||
};
|
||||
|
||||
// Sorteer: hoofddiagnoses eerst, dan actieve, dan op datum
|
||||
const sortedDiagnoses = [...diagnoses].sort((a, b) => {
|
||||
const aIsPrimary = a.category === 'primary-diagnosis';
|
||||
const bIsPrimary = b.category === 'primary-diagnosis';
|
||||
if (aIsPrimary && !bIsPrimary) return -1;
|
||||
if (!aIsPrimary && bIsPrimary) return 1;
|
||||
|
||||
const aIsActive = a.clinical_status === 'active';
|
||||
const bIsActive = b.clinical_status === 'active';
|
||||
if (aIsActive && !bIsActive) return -1;
|
||||
if (!aIsActive && bIsActive) return 1;
|
||||
|
||||
const aDate = a.recorded_date ? new Date(a.recorded_date).getTime() : 0;
|
||||
const bDate = b.recorded_date ? new Date(b.recorded_date).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 min-h-[500px]">
|
||||
{/* Master: Lijst (2 kolommen) */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">
|
||||
{diagnoses.length} diagnose{diagnoses.length !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
<Button onClick={handleNewDiagnosis} size="sm" disabled={intakes.length === 0}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nieuw
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{intakes.length === 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
Er is nog geen intake voor deze patiënt. Maak eerst een intake aan.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedDiagnoses.length === 0 && intakes.length > 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-3">
|
||||
<Stethoscope className="h-6 w-6 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-sm">Nog geen diagnoses</p>
|
||||
<p className="text-xs mt-1">Klik op "Nieuw" om er een toe te voegen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedDiagnoses.map((diagnosis) => (
|
||||
<DiagnosisListItem
|
||||
key={diagnosis.id}
|
||||
diagnosis={diagnosis}
|
||||
isSelected={selectedId === diagnosis.id}
|
||||
onClick={() => handleSelectDiagnosis(diagnosis.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail: Formulier (3 kolommen) */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg border border-slate-200 p-5">
|
||||
<DiagnosisDetailForm
|
||||
patientId={patientId}
|
||||
intakes={intakes}
|
||||
diagnosis={selectedDiagnosis}
|
||||
isNew={isNew}
|
||||
onSaved={handleSaved}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Diagnosis Overview Card Component
|
||||
*
|
||||
* Read-only weergave van een diagnose voor het patiënt-breed overzicht.
|
||||
* Toont ook de gekoppelde intake informatie.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { DiagnosisWithIntake } from '../actions';
|
||||
|
||||
interface DiagnosisOverviewCardProps {
|
||||
diagnosis: DiagnosisWithIntake;
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
// Status badge configuratie
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
active: {
|
||||
label: 'Actief',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-100 border-green-300',
|
||||
},
|
||||
remission: {
|
||||
label: 'In remissie',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100 border-blue-300',
|
||||
},
|
||||
resolved: {
|
||||
label: 'Opgelost',
|
||||
color: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100 border-slate-300',
|
||||
},
|
||||
inactive: {
|
||||
label: 'Inactief',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-100 border-amber-300',
|
||||
},
|
||||
};
|
||||
|
||||
// Severity labels
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
licht: 'Licht',
|
||||
matig: 'Matig',
|
||||
ernstig: 'Ernstig',
|
||||
};
|
||||
|
||||
export function DiagnosisOverviewCard({ diagnosis, patientId }: DiagnosisOverviewCardProps) {
|
||||
const [isNotesExpanded, setIsNotesExpanded] = useState(false);
|
||||
|
||||
const code = diagnosis.code_code || '';
|
||||
const description = diagnosis.code_display || '';
|
||||
const severity = diagnosis.severity_display || '';
|
||||
const status = diagnosis.clinical_status || 'active';
|
||||
const notes = diagnosis.note || '';
|
||||
const recordedDate = diagnosis.recorded_date ? new Date(diagnosis.recorded_date) : null;
|
||||
const isPrimary = diagnosis.category === 'primary-diagnosis';
|
||||
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
|
||||
const hasNotes = notes.trim().length > 0;
|
||||
|
||||
// Intake informatie
|
||||
const intake = diagnosis.intake;
|
||||
const intakeUrl = intake
|
||||
? `/epd/patients/${patientId}/intakes/${intake.id}/diagnosis`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-teal-300 hover:shadow-sm">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Code + beschrijving */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{code && description ? (
|
||||
<>
|
||||
<span className="font-mono">{code}</span>
|
||||
{' — '}
|
||||
<span>{description}</span>
|
||||
</>
|
||||
) : (
|
||||
code || description || 'Geen diagnose code'
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* HOOFD badge */}
|
||||
{isPrimary && (
|
||||
<Badge className="bg-green-600 text-white border-green-700 hover:bg-green-600">
|
||||
HOOFD
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${statusConfig.color} ${statusConfig.bgColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 space-y-3">
|
||||
{/* Meta informatie */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
|
||||
{severity && (
|
||||
<div>
|
||||
<span className="font-medium">Ernst:</span>{' '}
|
||||
<span>{SEVERITY_LABELS[severity] || severity}</span>
|
||||
</div>
|
||||
)}
|
||||
{recordedDate && (
|
||||
<div>
|
||||
<span className="font-medium">Datum:</span>{' '}
|
||||
<span>{format(recordedDate, 'd MMM yyyy', { locale: nl })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intake link */}
|
||||
{intake && intakeUrl && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">Intake:</span>
|
||||
<Link
|
||||
href={intakeUrl}
|
||||
className="text-teal-600 hover:text-teal-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{intake.title || intake.department || 'Intake'}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onderbouwing (expand/collapse) */}
|
||||
{hasNotes && (
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<button
|
||||
onClick={() => setIsNotesExpanded(!isNotesExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{isNotesExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<span>Onderbouwing</span>
|
||||
</button>
|
||||
{isNotesExpanded && (
|
||||
<div className="mt-2 pl-6 text-sm text-slate-600 whitespace-pre-line">
|
||||
{notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bewerk link naar intake */}
|
||||
{intakeUrl && (
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={intakeUrl}>
|
||||
Bewerken in intake
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Diagnose Overzicht Pagina
|
||||
*
|
||||
* Master-detail layout voor diagnoses:
|
||||
* - Links: lijst van diagnoses met selectie
|
||||
* - Rechts: formulier voor bekijken/bewerken/toevoegen
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { getPatientDiagnoses, getPatientIntakes } from './actions';
|
||||
import { DiagnosisMasterDetail } from './components/diagnosis-master-detail';
|
||||
|
||||
export default async function DiagnosePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: patientId } = await params;
|
||||
|
||||
// Haal diagnoses en intakes parallel op
|
||||
const [diagnoses, intakes] = await Promise.all([
|
||||
getPatientDiagnoses(patientId),
|
||||
getPatientIntakes(patientId),
|
||||
]);
|
||||
|
||||
// Tel actieve diagnoses
|
||||
const activeDiagnoses = diagnoses.filter((d) => d.clinical_status === 'active');
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Activity className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-green-600">{activeDiagnoses.length} actief</span>
|
||||
<span className="text-slate-500"> van {diagnoses.length} totaal</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master-Detail Layout */}
|
||||
<Suspense fallback={<div className="text-sm text-slate-500">Laden...</div>}>
|
||||
<DiagnosisMasterDetail
|
||||
patientId={patientId}
|
||||
diagnoses={diagnoses}
|
||||
intakes={intakes}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -186,116 +186,6 @@ export async function deleteRiskAssessment(patientId: string, intakeId: string,
|
||||
revalidatePath(buildPath(patientId, intakeId));
|
||||
}
|
||||
|
||||
// ---------------- Anamneses ----------------
|
||||
export async function getAnamneses(intakeId: string) {
|
||||
const supabase = await getSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from('anamneses')
|
||||
.select('*')
|
||||
.eq('intake_id', intakeId)
|
||||
.order('anamnese_date', { ascending: false });
|
||||
if (error) {
|
||||
console.error('getAnamneses error', error);
|
||||
throw new Error('Kon anamneses niet ophalen');
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export interface AnamnesePayload {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
date: string;
|
||||
type: string;
|
||||
content: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function createAnamnese(payload: AnamnesePayload) {
|
||||
const supabase = await getSupabase();
|
||||
const { error } = await supabase.from('anamneses').insert({
|
||||
intake_id: payload.intakeId,
|
||||
anamnese_date: payload.date,
|
||||
anamnese_type: payload.type,
|
||||
content: payload.content,
|
||||
notes: payload.notes,
|
||||
});
|
||||
if (error) {
|
||||
console.error('createAnamnese error', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'anamnese'));
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId));
|
||||
}
|
||||
|
||||
export async function deleteAnamnese(patientId: string, intakeId: string, anamneseId: string) {
|
||||
const supabase = await getSupabase();
|
||||
const { error } = await supabase.from('anamneses').delete().eq('id', anamneseId);
|
||||
if (error) {
|
||||
console.error('deleteAnamnese error', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
revalidatePath(buildPath(patientId, intakeId, 'anamnese'));
|
||||
revalidatePath(buildPath(patientId, intakeId));
|
||||
}
|
||||
|
||||
// ---------------- Examinations (including ROM) ----------------
|
||||
export async function getExaminations(intakeId: string) {
|
||||
const supabase = await getSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from('examinations')
|
||||
.select('*')
|
||||
.eq('intake_id', intakeId)
|
||||
.order('examination_date', { ascending: false });
|
||||
if (error) {
|
||||
console.error('getExaminations error', error);
|
||||
throw new Error('Kon onderzoeken niet ophalen');
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export interface ExaminationPayload {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
date: string;
|
||||
type: string;
|
||||
findings: string;
|
||||
performer?: string;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
isRom?: boolean;
|
||||
}
|
||||
|
||||
export async function createExamination(payload: ExaminationPayload) {
|
||||
const supabase = await getSupabase();
|
||||
const { error } = await supabase.from('examinations').insert({
|
||||
intake_id: payload.intakeId,
|
||||
examination_date: payload.date,
|
||||
examination_type: payload.isRom ? 'ROM' : payload.type,
|
||||
findings: payload.findings,
|
||||
performed_by: payload.performer,
|
||||
reason: payload.reason,
|
||||
notes: payload.notes,
|
||||
});
|
||||
if (error) {
|
||||
console.error('createExamination error', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
const tab = payload.isRom ? 'rom' : 'examination';
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId, tab));
|
||||
revalidatePath(buildPath(payload.patientId, payload.intakeId));
|
||||
}
|
||||
|
||||
export async function deleteExamination(patientId: string, intakeId: string, examinationId: string) {
|
||||
const supabase = await getSupabase();
|
||||
const { error } = await supabase.from('examinations').delete().eq('id', examinationId);
|
||||
if (error) {
|
||||
console.error('deleteExamination error', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
revalidatePath(buildPath(patientId, intakeId, 'examination'));
|
||||
revalidatePath(buildPath(patientId, intakeId, 'rom'));
|
||||
revalidatePath(buildPath(patientId, intakeId));
|
||||
}
|
||||
|
||||
// ---------------- Diagnoses ----------------
|
||||
export async function getDiagnoses(intakeId: string) {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { createAnamnese, deleteAnamnese, type Anamnese } from '../../actions';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
const types = [
|
||||
'Psychiatrische anamnese',
|
||||
'Sociale anamnese',
|
||||
'Medische anamnese',
|
||||
'Familieanamnese',
|
||||
'Ontwikkelingsanamnese',
|
||||
'Overig',
|
||||
];
|
||||
|
||||
interface AnamneseManagerProps {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
anamneses: Anamnese[];
|
||||
}
|
||||
|
||||
export function AnamneseManager({ patientId, intakeId, anamneses }: AnamneseManagerProps) {
|
||||
const [form, setForm] = useState({
|
||||
date: '',
|
||||
type: types[0],
|
||||
content: '',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.date || !form.content) {
|
||||
setError('Datum en inhoud zijn verplicht.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createAnamnese({
|
||||
patientId,
|
||||
intakeId,
|
||||
date: form.date,
|
||||
type: form.type,
|
||||
content: form.content,
|
||||
notes: form.notes,
|
||||
});
|
||||
setForm({ ...form, content: '', notes: '' });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeletingId(id);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteAnamnese(patientId, intakeId, id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{anamneses.length === 0 && <p className="text-sm text-slate-500">Nog geen anamneses.</p>}
|
||||
{anamneses.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{item.anamnese_type}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(item.anamnese_date), 'd MMM yyyy', { locale: nl })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={deletingId === item.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
|
||||
>
|
||||
{deletingId === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 whitespace-pre-line">{item.content}</p>
|
||||
{item.notes && <p className="text-xs text-slate-500">Notities: {item.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Nieuwe anamnese</h3>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
>
|
||||
{types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder="Inhoud"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Notities"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getAnamneses } from '../actions';
|
||||
import { AnamneseManager } from './components/anamnese-manager';
|
||||
|
||||
export default async function IntakeAnamnesePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; intakeId: string }>;
|
||||
}) {
|
||||
const { id, intakeId } = await params;
|
||||
const anamneses = await getAnamneses(intakeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Anamnese</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Vastleggen van psychiatrische, sociale en andere anamneses.
|
||||
</p>
|
||||
</div>
|
||||
<AnamneseManager patientId={id} intakeId={intakeId} anamneses={anamneses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useState, useTransition, useCallback } from 'react';
|
||||
import { saveTreatmentAdvice } from '../../actions';
|
||||
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Loader2, Calendar, UserCircle, ClipboardList, CheckCircle2 } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const RichTextEditor = dynamic(
|
||||
@@ -238,20 +237,6 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 p-4 bg-slate-50 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Share2 className="h-4 w-4" /> Doorzetten naar behandelplan
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Gebruik dit advies als basis voor het behandelplan of koppel direct door.
|
||||
</p>
|
||||
<Link
|
||||
href={`/epd/patients/${patientId}/behandelplan`}
|
||||
className="inline-flex items-center justify-center rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-white"
|
||||
>
|
||||
Open behandelplan
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -56,9 +56,6 @@ export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
|
||||
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
|
||||
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
|
||||
{ name: 'Risicotaxatie', href: `${baseUrl}/risk` },
|
||||
{ name: 'Anamnese', href: `${baseUrl}/anamnese` },
|
||||
{ name: 'Onderzoeken', href: `${baseUrl}/examination` },
|
||||
{ name: 'ROM', href: `${baseUrl}/rom` },
|
||||
{ name: 'Diagnose', href: `${baseUrl}/diagnosis` },
|
||||
{ name: 'Behandeladvies', href: `${baseUrl}/behandeladvies` },
|
||||
], [baseUrl]);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { createExamination, deleteExamination, type Examination } from '../../actions';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
|
||||
const examinationTypes = ['Bloedonderzoek', 'Neuropsychologisch onderzoek', 'Psychodiagnostiek', 'IQ-test', 'Persoonlijkheidsonderzoek', 'Overig'];
|
||||
|
||||
interface ExaminationManagerProps {
|
||||
patientId: string;
|
||||
intakeId: string;
|
||||
examinations: Examination[];
|
||||
isRom?: boolean;
|
||||
}
|
||||
|
||||
export function ExaminationManager({ patientId, intakeId, examinations, isRom }: ExaminationManagerProps) {
|
||||
const filtered = examinations.filter((exam) =>
|
||||
isRom ? exam.examination_type === 'ROM' : exam.examination_type !== 'ROM'
|
||||
);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
date: '',
|
||||
type: examinationTypes[0],
|
||||
performer: '',
|
||||
findings: '',
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.date || !form.findings) {
|
||||
setError('Datum en bevindingen zijn verplicht.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createExamination({
|
||||
patientId,
|
||||
intakeId,
|
||||
date: form.date,
|
||||
type: form.type,
|
||||
findings: form.findings,
|
||||
performer: form.performer,
|
||||
reason: form.reason,
|
||||
notes: form.notes,
|
||||
isRom,
|
||||
});
|
||||
setForm({ ...form, findings: '', notes: '' });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeletingId(id);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteExamination(patientId, intakeId, id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verwijderen mislukt');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{isRom ? 'Nog geen ROM-metingen' : 'Nog geen onderzoeken geregistreerd.'}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((exam) => (
|
||||
<div key={exam.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{exam.examination_type}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(exam.examination_date), 'd MMM yyyy', { locale: nl })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(exam.id)}
|
||||
disabled={deletingId === exam.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
|
||||
>
|
||||
{deletingId === exam.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 whitespace-pre-line">{exam.findings}</p>
|
||||
{exam.notes && <p className="text-xs text-slate-500">Notities: {exam.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{isRom ? 'Nieuwe ROM-meting' : 'Nieuw onderzoek'}
|
||||
</h3>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
/>
|
||||
{!isRom && (
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
>
|
||||
{examinationTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={isRom ? 'Instrument / score' : 'Uitgevoerd door'}
|
||||
value={form.performer}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, performer: e.target.value }))}
|
||||
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={form.findings}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, findings: e.target.value }))}
|
||||
placeholder={isRom ? 'Score en interpretatie' : 'Bevindingen'}
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={form.reason}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, reason: e.target.value }))}
|
||||
placeholder="Reden"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Notities"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getExaminations } from '../actions';
|
||||
import { ExaminationManager } from './components/examination-manager';
|
||||
|
||||
export default async function IntakeExaminationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; intakeId: string }>;
|
||||
}) {
|
||||
const { id, intakeId } = await params;
|
||||
const examinations = await getExaminations(intakeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Onderzoeken</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Psychodiagnostiek, medische onderzoeken en rapportage.
|
||||
</p>
|
||||
</div>
|
||||
<ExaminationManager patientId={id} intakeId={intakeId} examinations={examinations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getExaminations } from '../actions';
|
||||
import { ExaminationManager } from '../examination/components/examination-manager';
|
||||
|
||||
export default async function IntakeRomPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; intakeId: string }>;
|
||||
}) {
|
||||
const { id, intakeId } = await params;
|
||||
const examinations = await getExaminations(intakeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">ROM-metingen</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Registeren van ROM-scores gekoppeld aan deze intake.
|
||||
</p>
|
||||
</div>
|
||||
<ExaminationManager
|
||||
patientId={id}
|
||||
intakeId={intakeId}
|
||||
examinations={examinations}
|
||||
isRom
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,14 +18,6 @@ import type { Intake } from '@/lib/types/intake';
|
||||
import { format } from 'date-fns';
|
||||
import { nl } from 'date-fns/locale';
|
||||
import { getPatientEncounters } from '@/app/epd/agenda/actions';
|
||||
import { getActiveCarePlan } from './behandelplan/actions';
|
||||
import type { SmartGoal, Intervention, Behandelstructuur, Evaluatiemoment } from '@/lib/types/behandelplan';
|
||||
|
||||
function extractHulpvraag(notes: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const firstSentence = notes.split(/[.!?]/)[0];
|
||||
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
|
||||
}
|
||||
|
||||
export default async function PatientDashboardPage({
|
||||
params,
|
||||
@@ -35,10 +27,9 @@ export default async function PatientDashboardPage({
|
||||
const { id } = await params;
|
||||
|
||||
// Fetch all data in parallel for better performance
|
||||
const [intakesResult, encountersResult, carePlanResult] = await Promise.all([
|
||||
const [intakesResult, encountersResult] = await Promise.all([
|
||||
getIntakesByPatientId(id).catch(() => [] as Intake[]),
|
||||
getPatientEncounters(id).catch(() => []),
|
||||
getActiveCarePlan(id).catch(() => null),
|
||||
]);
|
||||
|
||||
// Process intakes
|
||||
@@ -58,16 +49,6 @@ export default async function PatientDashboardPage({
|
||||
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
|
||||
}
|
||||
|
||||
// Process care plan and get hulpvraag from already-fetched intakes
|
||||
const activeCarePlan = carePlanResult;
|
||||
let hulpvraag: string | null = null;
|
||||
if (activeCarePlan?.based_on_intake_id) {
|
||||
const linkedIntake = intakesResult.find((i: Intake) => i.id === activeCarePlan.based_on_intake_id);
|
||||
if (linkedIntake?.notes) {
|
||||
hulpvraag = extractHulpvraag(linkedIntake.notes);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Page Header */}
|
||||
@@ -266,154 +247,6 @@ export default async function PatientDashboardPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Behandelplan Section */}
|
||||
{activeCarePlan && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Actief Behandelplan</h3>
|
||||
<Link
|
||||
href={`/epd/patients/${id}/behandelplan`}
|
||||
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
|
||||
>
|
||||
Bekijk volledig plan →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hulpvraag */}
|
||||
{hulpvraag && (
|
||||
<div className="mb-4 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-xs font-medium text-slate-600 mb-1">Hulpvraag</p>
|
||||
<p className="text-sm text-slate-700 italic">“{hulpvraag}”</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Behandelstructuur */}
|
||||
{activeCarePlan.behandelstructuur && (
|
||||
<div className="mb-4 p-3 bg-teal-50 rounded-lg">
|
||||
<p className="text-xs font-medium text-teal-700 mb-2">Behandelstructuur</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-teal-900">
|
||||
{(() => {
|
||||
const bs = activeCarePlan.behandelstructuur as unknown as Behandelstructuur;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-medium">Duur:</span> {bs.duur}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Frequentie:</span> {bs.frequentie}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Aantal sessies:</span> {bs.aantalSessies}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Vorm:</span> {bs.vorm}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Doelen Overzicht */}
|
||||
{activeCarePlan.goals && Array.isArray(activeCarePlan.goals) && activeCarePlan.goals.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-slate-600 mb-2">Doelen ({activeCarePlan.goals.length})</p>
|
||||
<div className="space-y-2">
|
||||
{(activeCarePlan.goals as unknown as SmartGoal[]).slice(0, 3).map((goal) => (
|
||||
<div key={goal.id} className="p-2 bg-slate-50 rounded border border-slate-200">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<p className="text-sm font-medium text-slate-900">{goal.title}</p>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
goal.status === 'bezig' ? 'bg-blue-50 text-blue-700' :
|
||||
goal.status === 'gehaald' ? 'bg-green-50 text-green-700' :
|
||||
'bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{goal.status === 'bezig' ? 'Bezig' :
|
||||
goal.status === 'gehaald' ? 'Gehaald' :
|
||||
goal.status === 'niet_gestart' ? 'Niet gestart' : goal.status}
|
||||
</span>
|
||||
</div>
|
||||
{goal.progress > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 transition-all"
|
||||
style={{ width: `${goal.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{goal.progress}% voltooid</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{activeCarePlan.goals.length > 3 && (
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
+{activeCarePlan.goals.length - 3} meer doelen
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interventies Overzicht */}
|
||||
{activeCarePlan.activities && Array.isArray(activeCarePlan.activities) && activeCarePlan.activities.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-slate-600 mb-2">Interventies ({activeCarePlan.activities.length})</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(activeCarePlan.activities as unknown as Intervention[]).slice(0, 5).map((intervention) => (
|
||||
<span
|
||||
key={intervention.id}
|
||||
className="px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs font-medium"
|
||||
>
|
||||
{intervention.name}
|
||||
</span>
|
||||
))}
|
||||
{activeCarePlan.activities.length > 5 && (
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
+{activeCarePlan.activities.length - 5} meer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aankomende Evaluatiemomenten */}
|
||||
{activeCarePlan.evaluatiemomenten &&
|
||||
Array.isArray(activeCarePlan.evaluatiemomenten) &&
|
||||
activeCarePlan.evaluatiemomenten.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-600 mb-2">Aankomende evaluatiemomenten</p>
|
||||
<div className="space-y-2">
|
||||
{(activeCarePlan.evaluatiemomenten as unknown as Evaluatiemoment[])
|
||||
.filter((evaluatie) => evaluatie.status === 'gepland')
|
||||
.slice(0, 2)
|
||||
.map((evaluatie) => (
|
||||
<div key={evaluatie.id} className="p-2 bg-amber-50 rounded border border-amber-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
{evaluatie.type === 'tussentijds' ? 'Tussentijdse evaluatie' :
|
||||
evaluatie.type === 'eind' ? 'Eindevaluatie' : 'Crisis evaluatie'}
|
||||
</p>
|
||||
{evaluatie.plannedDate && (
|
||||
<p className="text-xs text-amber-700 mt-0.5">
|
||||
Week {evaluatie.weekNumber} • {format(new Date(evaluatie.plannedDate), 'd MMM yyyy', { locale: nl })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
|
||||
Gepland
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Steps Section */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Behandelaar Rapportage - Level 1
|
||||
*
|
||||
* BI dashboards, KPI's en statistieken.
|
||||
*/
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
Rapportage
|
||||
</h1>
|
||||
|
||||
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
|
||||
<p className="text-slate-600 text-lg mb-2">
|
||||
Behandelaar Rapportage
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
BI dashboards, KPI's, trends en statistieken
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-4">
|
||||
Placeholder - Not designed yet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* =============================================================================
|
||||
Mini-ECD Design System
|
||||
Gebaseerd op ux-stylesheet.md (v2, UX-geoptimaliseerd)
|
||||
============================================================================= */
|
||||
|
||||
:root {
|
||||
/* Basiskleuren */
|
||||
--color-bg: #F8FAFC;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-secondary: #F1F5F9;
|
||||
--color-text: #0F172A;
|
||||
--color-text-secondary: #475569;
|
||||
--color-border: #E2E8F0;
|
||||
|
||||
/* Brand & Primary */
|
||||
--color-brand: #3B82F6;
|
||||
--color-brand-hover: #2563EB;
|
||||
--color-brand-active: #1D4ED8;
|
||||
--color-brand-subtle: #EFF6FF;
|
||||
|
||||
/* Neutral CTA */
|
||||
--color-neutral: #334155;
|
||||
--color-neutral-hover: #1F2937;
|
||||
|
||||
/* Module-accenten */
|
||||
--color-module-appointments: #16A34A;
|
||||
--color-module-appointments-bg: #E8F8EF;
|
||||
--color-module-appointments-border: #CDECDC;
|
||||
|
||||
--color-module-meds: #F59E0B;
|
||||
--color-module-meds-bg: #FEF6DC;
|
||||
--color-module-meds-border: #F6E7B6;
|
||||
|
||||
--color-module-labs: #F97316;
|
||||
--color-module-labs-bg: #FFEBDC;
|
||||
--color-module-labs-border: #FFD2B8;
|
||||
|
||||
/* Status & Feedback */
|
||||
--color-success: #16A34A;
|
||||
--color-success-subtle: #ECFDF5;
|
||||
--color-warning: #EAB308;
|
||||
--color-warning-subtle: #FEFCE8;
|
||||
--color-error: #DC2626;
|
||||
--color-error-subtle: #FEF2F2;
|
||||
--color-info: #3B82F6;
|
||||
--color-info-subtle: #EFF6FF;
|
||||
|
||||
/* Badges (Severity DSM-light) */
|
||||
--color-badge-low-bg: #E5E7EB;
|
||||
--color-badge-low-text: #374151;
|
||||
--color-badge-medium-bg: #FEF3C7;
|
||||
--color-badge-medium-text: #92400E;
|
||||
--color-badge-high-bg: #FEE2E2;
|
||||
--color-badge-high-text: #991B1B;
|
||||
|
||||
/* Formulieren */
|
||||
--color-input-bg: #FFFFFF;
|
||||
--color-input-text: #0F172A;
|
||||
--color-input-placeholder: #94A3B8;
|
||||
--color-input-border: #CBD5E1;
|
||||
--color-input-border-hover: #94A3B8;
|
||||
--color-input-focus: #3B82F6;
|
||||
--color-input-focus-border: #2563EB;
|
||||
--color-input-disabled-bg: #F1F5F9;
|
||||
--color-input-disabled-text: #94A3B8;
|
||||
--color-input-invalid-border: #DC2626;
|
||||
--color-input-invalid-text: #B91C1C;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: 0 2px 6px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 8px 20px rgba(15, 23, 42, 0.10);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Basiskleuren */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-text: var(--color-text);
|
||||
--color-border: var(--color-border);
|
||||
|
||||
/* Brand */
|
||||
--color-brand: var(--color-brand);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-error: var(--color-error);
|
||||
--color-info: var(--color-info);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
/* Dark mode (optioneel, MVP gebruikt licht thema) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0a0a0a;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-text: #ededed;
|
||||
--color-text-secondary: #a1a1a1;
|
||||
--color-border: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Focus styles (toegankelijkheid) */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* AI source highlighting (gebruikt in Intake editor) */
|
||||
.ai-source-highlight {
|
||||
background-color: #fef08a; /* lichtgeel */
|
||||
border-radius: 2px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function LoginForm() {
|
||||
|
||||
// Redirect path based on interface preference
|
||||
const getRedirectPath = () => {
|
||||
return interfacePreference === 'cortex' ? '/epd/cortex' : '/epd/clients'
|
||||
return interfacePreference === 'cortex' ? '/epd/cortex' : '/epd/patients'
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -1,36 +1,18 @@
|
||||
/**
|
||||
* Robots.txt Generator
|
||||
*
|
||||
* Generates robots.txt for SEO.
|
||||
* Next.js will automatically serve this at /robots.txt
|
||||
*
|
||||
* ECD is een afgeschermd systeem — zoekmachines mogen niets indexeren.
|
||||
*/
|
||||
|
||||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/_next/',
|
||||
'/admin/',
|
||||
],
|
||||
},
|
||||
{
|
||||
userAgent: 'Googlebot',
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/api/',
|
||||
'/_next/',
|
||||
],
|
||||
disallow: '/',
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function SetPasswordPage() {
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
router.push('/epd/clients')
|
||||
router.push('/epd/patients')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Sitemap Generator
|
||||
*
|
||||
* Generates sitemap.xml for SEO.
|
||||
* Next.js will automatically serve this at /sitemap.xml
|
||||
*/
|
||||
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getAllReleases } from '@/lib/mdx/documentatie'
|
||||
import { getAllPosts, getAllSeries } from '@/lib/mdx/blog'
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
const currentDate = new Date()
|
||||
|
||||
// Fetch all documentation releases dynamically
|
||||
const releases = await getAllReleases()
|
||||
|
||||
const releaseUrls: MetadataRoute.Sitemap = releases.map((release) => {
|
||||
const releaseDate = new Date(release.frontmatter.releaseDate)
|
||||
const isValidDate = !isNaN(releaseDate.getTime())
|
||||
|
||||
return {
|
||||
url: `${baseUrl}/documentatie/${release.slug}`,
|
||||
lastModified: isValidDate ? releaseDate : currentDate,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch all blog posts dynamically
|
||||
const posts = await getAllPosts()
|
||||
const postUrls: MetadataRoute.Sitemap = posts.map((post) => {
|
||||
const postDate = new Date(post.frontmatter.date)
|
||||
const isValidDate = !isNaN(postDate.getTime())
|
||||
|
||||
return {
|
||||
url: `${baseUrl}/blog/serie/${post.seriesId}/${post.slug}`,
|
||||
lastModified: isValidDate ? postDate : currentDate,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch all blog series
|
||||
const series = await getAllSeries()
|
||||
const seriesUrls: MetadataRoute.Sitemap = series.map((serie) => ({
|
||||
url: `${baseUrl}/blog/serie/${serie.id}`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}))
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/documentatie`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
...seriesUrls,
|
||||
...postUrls,
|
||||
...releaseUrls,
|
||||
{
|
||||
url: `${baseUrl}/contact`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, FileText, CheckCircle2 } from 'lucide-react';
|
||||
import { FHIR_STATUS_LABELS, type FhirCarePlanStatus } from '@/lib/types/behandelplan';
|
||||
|
||||
interface CarePlanSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
version: number | null;
|
||||
created_at: string | null;
|
||||
published_at: string | null;
|
||||
}
|
||||
|
||||
interface BehandelplanListProps {
|
||||
plans: CarePlanSummary[];
|
||||
selectedPlanId: string | null;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
onCreateNew: () => void;
|
||||
isCreating?: boolean;
|
||||
}
|
||||
|
||||
export function BehandelplanList({
|
||||
plans,
|
||||
selectedPlanId,
|
||||
onSelectPlan,
|
||||
onCreateNew,
|
||||
isCreating = false,
|
||||
}: BehandelplanListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Behandelplannen ({plans.length})
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={onCreateNew}
|
||||
disabled={isCreating}
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nieuw
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{plans.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Nog geen behandelplannen aangemaakt
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plans.map((plan) => {
|
||||
const isSelected = plan.id === selectedPlanId;
|
||||
const statusInfo = FHIR_STATUS_LABELS[plan.status as FhirCarePlanStatus] || {
|
||||
label: plan.status,
|
||||
color: '#6b7280',
|
||||
};
|
||||
const isActive = plan.status === 'active';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => onSelectPlan(plan.id)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 ring-1 ring-indigo-500'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isActive && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<span className={`font-medium text-sm ${isSelected ? 'text-indigo-900' : 'text-slate-900'}`}>
|
||||
{plan.title}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: statusInfo.color,
|
||||
color: 'white',
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
||||
{plan.version && <span>v{plan.version}</span>}
|
||||
{plan.created_at && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{new Date(plan.created_at).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{plan.published_at && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-green-600">Gepubliceerd</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, Check, Loader2 } from 'lucide-react';
|
||||
|
||||
interface EditableSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
editForm?: ReactNode;
|
||||
onSave?: () => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
isEditing?: boolean;
|
||||
onEditChange?: (isEditing: boolean) => void;
|
||||
canEdit?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EditableSection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
editForm,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing: externalIsEditing,
|
||||
onEditChange,
|
||||
canEdit = true,
|
||||
className = '',
|
||||
}: EditableSectionProps) {
|
||||
const [internalIsEditing, setInternalIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Use external or internal state
|
||||
const isEditing = externalIsEditing ?? internalIsEditing;
|
||||
const setIsEditing = onEditChange ?? setInternalIsEditing;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave();
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isEditing && editForm ? (
|
||||
<div className="space-y-4">
|
||||
{editForm}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Opslaan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple inline edit buttons for list items
|
||||
interface ItemActionsProps {
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export function ItemActions({ onEdit, onDelete, isDeleting }: ItemActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Behandeldoel, GOAL_STATUS_LABELS } from '@/lib/types/behandelplan';
|
||||
import { LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
||||
import { Target, Pencil, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { BehandeldoelForm } from './behandeldoel-form';
|
||||
|
||||
interface BehandeldoelCardProps {
|
||||
doel: Behandeldoel;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (doel: Behandeldoel) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandeldoel Card
|
||||
* View mode: Compact card met doel + interventies
|
||||
* Edit mode: Inline form met alle velden
|
||||
*/
|
||||
export function BehandeldoelCard({
|
||||
doel,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
className,
|
||||
}: BehandeldoelCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<BehandeldoelForm
|
||||
doel={doel}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = LIFE_DOMAIN_META[doel.lifeDomain];
|
||||
const statusInfo = GOAL_STATUS_LABELS[doel.status];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'transition-all hover:border-indigo-300 hover:shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Target className="h-4 w-4 text-indigo-600 shrink-0" />
|
||||
<h3 className="font-medium text-slate-900 truncate">{doel.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-0"
|
||||
style={{ backgroundColor: meta.color, color: 'white' }}
|
||||
>
|
||||
{meta.shortLabel}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0 space-y-3">
|
||||
{/* Client version (B1 tekst) - altijd zichtbaar */}
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-2.5">
|
||||
<p className="text-sm text-blue-800 italic">
|
||||
“{doel.clientVersion}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interventies */}
|
||||
{doel.interventies.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Aanpak
|
||||
</span>
|
||||
<ul className="space-y-1">
|
||||
{doel.interventies.map((int) => (
|
||||
<li key={int.id} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>
|
||||
<span className="font-medium text-slate-700">{int.name}</span>
|
||||
{int.description && (
|
||||
<span className="text-slate-500"> - {int.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress & timeline */}
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Week {doel.startWeek}-{doel.endWeek}</span>
|
||||
<span>{doel.progress}%</span>
|
||||
</div>
|
||||
<Progress value={doel.progress} className="h-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-slate-500 h-8 w-8 p-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="text-slate-500 h-8 w-8 p-0 hover:text-indigo-600"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details (optional) */}
|
||||
{isExpanded && (
|
||||
<div className="pt-2 border-t border-slate-100 text-xs text-slate-500 space-y-1">
|
||||
<p>Leefgebied: {meta.label}</p>
|
||||
<p>Status: {statusInfo.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
type Behandeldoel,
|
||||
type EmbeddedInterventie,
|
||||
type GoalStatus,
|
||||
GOAL_STATUSES,
|
||||
GOAL_STATUS_LABELS,
|
||||
createEmptyEmbeddedInterventie,
|
||||
} from '@/lib/types/behandelplan';
|
||||
import { type LifeDomain, LIFE_DOMAINS, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
||||
import { Plus, X, Sparkles, Trash2, Save } from 'lucide-react';
|
||||
|
||||
interface BehandeldoelFormProps {
|
||||
doel: Behandeldoel;
|
||||
onSave: (doel: Behandeldoel) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline edit form voor Behandeldoel
|
||||
* Alle velden in één uitklapbare card
|
||||
*/
|
||||
export function BehandeldoelForm({
|
||||
doel,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
className,
|
||||
}: BehandeldoelFormProps) {
|
||||
const [formData, setFormData] = useState<Behandeldoel>(doel);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!onDelete) return;
|
||||
if (!confirm('Weet je zeker dat je dit behandeldoel wilt verwijderen?')) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof Behandeldoel>(
|
||||
field: K,
|
||||
value: Behandeldoel[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addInterventie = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
interventies: [...prev.interventies, createEmptyEmbeddedInterventie()],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateInterventie = (
|
||||
index: number,
|
||||
field: keyof EmbeddedInterventie,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
interventies: prev.interventies.map((int, i) =>
|
||||
i === index ? { ...int, [field]: value } : int
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeInterventie = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
interventies: prev.interventies.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const isValid =
|
||||
formData.title.trim().length >= 5 &&
|
||||
formData.clientVersion.trim().length >= 5;
|
||||
|
||||
return (
|
||||
<Card className={cn('border-indigo-300 shadow-md', className)}>
|
||||
<CardHeader className="p-4 pb-2 border-b bg-indigo-50/50">
|
||||
<CardTitle className="text-base font-medium text-indigo-900">
|
||||
Behandeldoel bewerken
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Doel titel */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title" className="text-sm font-medium">
|
||||
Doel <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
placeholder="Bijv. Weer 4 dagen per week stabiel kunnen werken"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client versie (B1) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="clientVersion" className="text-sm font-medium">
|
||||
Cliënt-versie (B1) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
|
||||
disabled // TODO: Implementeer AI generatie
|
||||
>
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
Genereer met AI
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="clientVersion"
|
||||
value={formData.clientVersion}
|
||||
onChange={(e) => updateField('clientVersion', e.target.value)}
|
||||
placeholder="Bijv. Ik kan weer 4 dagen werken zonder veel stress"
|
||||
className="text-sm min-h-[60px] resize-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Formuleer in eenvoudige taal (B1-niveau) zodat de cliënt het begrijpt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Leefgebied & Periode - inline */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Leefgebied</Label>
|
||||
<Select
|
||||
value={formData.lifeDomain}
|
||||
onValueChange={(v) => updateField('lifeDomain', v as LifeDomain)}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LIFE_DOMAINS.map((domain) => {
|
||||
const meta = LIFE_DOMAIN_META[domain];
|
||||
return (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{meta.emoji}</span>
|
||||
<span>{meta.shortLabel}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Periode</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={formData.startWeek}
|
||||
onChange={(e) =>
|
||||
updateField('startWeek', parseInt(e.target.value) || 1)
|
||||
}
|
||||
className="w-16 text-sm text-center"
|
||||
/>
|
||||
<span className="text-slate-500 text-sm">t/m</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={formData.endWeek}
|
||||
onChange={(e) =>
|
||||
updateField('endWeek', parseInt(e.target.value) || 8)
|
||||
}
|
||||
className="w-16 text-sm text-center"
|
||||
/>
|
||||
<span className="text-slate-500 text-sm">weken</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interventies */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Aanpak (interventies)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addInterventie}
|
||||
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Toevoegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{formData.interventies.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 italic py-2">
|
||||
Nog geen interventies toegevoegd
|
||||
</p>
|
||||
) : (
|
||||
formData.interventies.map((int, index) => (
|
||||
<div
|
||||
key={int.id}
|
||||
className="flex items-start gap-2 p-2 bg-slate-50 rounded-md"
|
||||
>
|
||||
<div className="flex-1 grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
value={int.name}
|
||||
onChange={(e) =>
|
||||
updateInterventie(index, 'name', e.target.value)
|
||||
}
|
||||
placeholder="CGT"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={int.description}
|
||||
onChange={(e) =>
|
||||
updateInterventie(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Korte beschrijving"
|
||||
className="text-sm col-span-2"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeInterventie(index)}
|
||||
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & Voortgang */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as GoalStatus)}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GOAL_STATUSES.map((status) => {
|
||||
const info = GOAL_STATUS_LABELS[status];
|
||||
return (
|
||||
<SelectItem key={status} value={status}>
|
||||
{info.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">
|
||||
Voortgang: {formData.progress}%
|
||||
</Label>
|
||||
<Slider
|
||||
value={[formData.progress]}
|
||||
onValueChange={([v]) => updateField('progress', v)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
{onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{isDeleting ? 'Verwijderen...' : 'Verwijderen'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || isSaving}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{isSaving ? 'Opslaan...' : 'Opslaan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
type Behandeldoel,
|
||||
type Behandelstructuur,
|
||||
type Evaluatiemoment,
|
||||
type Veiligheidsplan,
|
||||
type SmartGoal,
|
||||
type Intervention,
|
||||
type FhirCarePlanStatus,
|
||||
FHIR_STATUS_LABELS,
|
||||
transformToFlat,
|
||||
createEmptyBehandeldoel,
|
||||
calculateBehandeldoelenProgress,
|
||||
} from '@/lib/types/behandelplan';
|
||||
import { type LifeDomainScore } from '@/lib/types/leefgebieden';
|
||||
import { ContextHeader } from './context-header';
|
||||
import { BehandeldoelCard } from './behandeldoel-card';
|
||||
import { PlanningSection } from './planning-section';
|
||||
import { Plus, Sparkles, FileText, CheckCircle2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface Condition {
|
||||
id: string;
|
||||
category: string;
|
||||
code_display: string;
|
||||
severity_code: string | null;
|
||||
severity_display: string | null;
|
||||
}
|
||||
|
||||
interface CarePlan {
|
||||
id: string;
|
||||
title: string;
|
||||
status: FhirCarePlanStatus;
|
||||
version: number | null;
|
||||
goals: SmartGoal[] | null;
|
||||
activities: Intervention[] | null;
|
||||
behandelstructuur: Behandelstructuur | null;
|
||||
sessie_planning: unknown[] | null;
|
||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
||||
veiligheidsplan: Veiligheidsplan | null;
|
||||
created_at: string | null;
|
||||
published_at: string | null;
|
||||
period_start: string | null;
|
||||
}
|
||||
|
||||
interface BehandelplanFlatProps {
|
||||
patientId: string;
|
||||
carePlan: CarePlan | null;
|
||||
condition: Condition | null;
|
||||
hulpvraag: string | null;
|
||||
lifeDomainScores: LifeDomainScore[] | null;
|
||||
// Callbacks
|
||||
onGenerate?: () => Promise<void>;
|
||||
onCreateManual?: () => Promise<void>;
|
||||
onStatusChange?: (status: FhirCarePlanStatus) => Promise<void>;
|
||||
onSaveBehandeldoel?: (doel: Behandeldoel) => Promise<void>;
|
||||
onDeleteBehandeldoel?: (doelId: string) => Promise<void>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BehandelplanFlat - Hoofdcomponent voor plat behandelplan
|
||||
*
|
||||
* 3 blokken:
|
||||
* 1. Context Header (read-only): Diagnose, hulpvraag, leefgebieden
|
||||
* 2. Behandeldoelen (editable): Cards met inline interventies
|
||||
* 3. Planning & Evaluatie (collapsed): Evaluaties, sessies, veiligheidsplan
|
||||
*/
|
||||
export function BehandelplanFlat({
|
||||
patientId,
|
||||
carePlan,
|
||||
condition,
|
||||
hulpvraag,
|
||||
lifeDomainScores,
|
||||
onGenerate,
|
||||
onCreateManual,
|
||||
onStatusChange,
|
||||
onSaveBehandeldoel,
|
||||
onDeleteBehandeldoel,
|
||||
className,
|
||||
}: BehandelplanFlatProps) {
|
||||
const [editingDoelId, setEditingDoelId] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Transform old structure to flat
|
||||
const behandeldoelen: Behandeldoel[] = carePlan?.goals && carePlan?.activities
|
||||
? transformToFlat(carePlan.goals, carePlan.activities)
|
||||
: [];
|
||||
|
||||
const totalProgress = calculateBehandeldoelenProgress(behandeldoelen);
|
||||
const statusInfo = carePlan?.status ? FHIR_STATUS_LABELS[carePlan.status] : null;
|
||||
|
||||
// Handlers
|
||||
const handleGenerate = async () => {
|
||||
if (!onGenerate) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await onGenerate();
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateManual = async () => {
|
||||
if (!onCreateManual) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateManual();
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDoel = async (doel: Behandeldoel) => {
|
||||
if (!onSaveBehandeldoel) return;
|
||||
await onSaveBehandeldoel(doel);
|
||||
setEditingDoelId(null);
|
||||
};
|
||||
|
||||
const handleDeleteDoel = async (doelId: string) => {
|
||||
if (!onDeleteBehandeldoel) return;
|
||||
await onDeleteBehandeldoel(doelId);
|
||||
setEditingDoelId(null);
|
||||
};
|
||||
|
||||
const handleAddDoel = () => {
|
||||
const newDoel = createEmptyBehandeldoel();
|
||||
// Start editing immediately
|
||||
setEditingDoelId(newDoel.id);
|
||||
// We need to save this empty doel first, then edit
|
||||
// For now, we'll handle this in the parent component
|
||||
};
|
||||
|
||||
// No plan yet - show creation options
|
||||
if (!carePlan) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Context header */}
|
||||
<ContextHeader
|
||||
condition={condition}
|
||||
hulpvraag={hulpvraag}
|
||||
lifeDomainScores={lifeDomainScores}
|
||||
/>
|
||||
|
||||
{/* Creation options */}
|
||||
<Card className="border-dashed border-2 border-slate-300">
|
||||
<CardContent className="p-6 text-center space-y-4">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center">
|
||||
<FileText className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">
|
||||
Nog geen behandelplan
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Maak een nieuw behandelplan aan
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{isGenerating ? 'Genereren...' : 'Genereer met AI'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCreateManual}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{isCreating ? 'Aanmaken...' : 'Handmatig aanmaken'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Plan header with status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
{carePlan.title || 'Behandelplan'}
|
||||
</h2>
|
||||
{statusInfo && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
{carePlan.version && (
|
||||
<span className="text-sm text-slate-500">v{carePlan.version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Overall progress */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<span>Voortgang:</span>
|
||||
<div className="w-24">
|
||||
<Progress value={totalProgress} className="h-2" />
|
||||
</div>
|
||||
<span className="font-medium">{totalProgress}%</span>
|
||||
</div>
|
||||
{/* Status actions */}
|
||||
{carePlan.status === 'draft' && onStatusChange && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onStatusChange('active')}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
Activeren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block 1: Context Header */}
|
||||
<ContextHeader
|
||||
condition={condition}
|
||||
hulpvraag={hulpvraag}
|
||||
lifeDomainScores={lifeDomainScores}
|
||||
/>
|
||||
|
||||
{/* Block 2: Behandeldoelen */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-slate-700 uppercase tracking-wide">
|
||||
Behandeldoelen ({behandeldoelen.length})
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddDoel}
|
||||
className="text-indigo-600 hover:text-indigo-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nieuw doel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{behandeldoelen.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
Nog geen behandeldoelen. Klik op "Nieuw doel" om te beginnen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{behandeldoelen.map((doel) => (
|
||||
<BehandeldoelCard
|
||||
key={doel.id}
|
||||
doel={doel}
|
||||
isEditing={editingDoelId === doel.id}
|
||||
onEdit={() => setEditingDoelId(doel.id)}
|
||||
onSave={handleSaveDoel}
|
||||
onCancel={() => setEditingDoelId(null)}
|
||||
onDelete={() => handleDeleteDoel(doel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block 3: Planning & Evaluatie */}
|
||||
<PlanningSection
|
||||
behandelstructuur={carePlan.behandelstructuur}
|
||||
evaluatiemomenten={carePlan.evaluatiemomenten}
|
||||
veiligheidsplan={carePlan.veiligheidsplan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type LifeDomain, type LifeDomainScore, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
|
||||
import { Stethoscope, MessageSquareQuote } from 'lucide-react';
|
||||
|
||||
interface Condition {
|
||||
id: string;
|
||||
category: string;
|
||||
code_display: string;
|
||||
severity_code: string | null;
|
||||
severity_display: string | null;
|
||||
}
|
||||
|
||||
interface ContextHeaderProps {
|
||||
condition: Condition | null;
|
||||
hulpvraag: string | null;
|
||||
lifeDomainScores: LifeDomainScore[] | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blok 1: Context Header
|
||||
* Read-only samenvatting van diagnose, hulpvraag en leefgebieden
|
||||
*/
|
||||
export function ContextHeader({
|
||||
condition,
|
||||
hulpvraag,
|
||||
lifeDomainScores,
|
||||
className,
|
||||
}: ContextHeaderProps) {
|
||||
// Filter op leefgebieden met hoge prioriteit of lage scores
|
||||
const priorityDomains = lifeDomainScores?.filter(
|
||||
(s) => s.priority === 'hoog' || s.baseline <= 2
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-slate-50 border-slate-200', className)}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{/* Diagnose */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Stethoscope className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Diagnose
|
||||
</span>
|
||||
{condition ? (
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{condition.code_display}
|
||||
{condition.severity_display && (
|
||||
<span className="text-slate-500 font-normal ml-1">
|
||||
({condition.severity_display})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 italic">Geen diagnose vastgesteld</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hulpvraag */}
|
||||
{hulpvraag && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquareQuote className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Hulpvraag
|
||||
</span>
|
||||
<p className="text-sm text-slate-700 italic">“{hulpvraag}”</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leefgebieden bars */}
|
||||
{priorityDomains.length > 0 && (
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide block mb-2">
|
||||
Prioritaire leefgebieden
|
||||
</span>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{priorityDomains.map((score) => (
|
||||
<LifeDomainBar key={score.domain} score={score} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LifeDomainBarProps {
|
||||
score: LifeDomainScore;
|
||||
}
|
||||
|
||||
function LifeDomainBar({ score }: LifeDomainBarProps) {
|
||||
const meta = LIFE_DOMAIN_META[score.domain];
|
||||
const progressPercent = (score.baseline / 5) * 100;
|
||||
const targetPercent = (score.target / 5) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-slate-700">
|
||||
{meta.emoji} {meta.shortLabel}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{score.baseline} → {score.target}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full relative overflow-hidden">
|
||||
{/* Target indicator */}
|
||||
<div
|
||||
className="absolute h-full w-0.5 bg-slate-400 z-10"
|
||||
style={{ left: `${targetPercent}%` }}
|
||||
/>
|
||||
{/* Current progress */}
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: meta.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { BehandelplanFlat } from './behandelplan-flat';
|
||||
export { ContextHeader } from './context-header';
|
||||
export { BehandeldoelCard } from './behandeldoel-card';
|
||||
export { BehandeldoelForm } from './behandeldoel-form';
|
||||
export { PlanningSection } from './planning-section';
|
||||
@@ -1,251 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
type Behandelstructuur,
|
||||
type Evaluatiemoment,
|
||||
type Veiligheidsplan,
|
||||
EVALUATION_STATUSES,
|
||||
} from '@/lib/types/behandelplan';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Clock,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Phone,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface PlanningSectionProps {
|
||||
behandelstructuur: Behandelstructuur | null;
|
||||
evaluatiemomenten: Evaluatiemoment[] | null;
|
||||
veiligheidsplan: Veiligheidsplan | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blok 3: Planning & Evaluatie
|
||||
* Collapsed by default, bevat:
|
||||
* - Evaluatiemomenten
|
||||
* - Behandelstructuur
|
||||
* - Veiligheidsplan (indien aanwezig)
|
||||
*/
|
||||
export function PlanningSection({
|
||||
behandelstructuur,
|
||||
evaluatiemomenten,
|
||||
veiligheidsplan,
|
||||
className,
|
||||
}: PlanningSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const evaluatiesCount = evaluatiemomenten?.length || 0;
|
||||
const hasVeiligheidsplan = !!veiligheidsplan;
|
||||
|
||||
// Count pending evaluations
|
||||
const pendingEvaluaties =
|
||||
evaluatiemomenten?.filter((e) => e.status === 'gepland').length || 0;
|
||||
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
{/* Collapsed header */}
|
||||
<CardHeader
|
||||
className="p-3 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<Calendar className="h-4 w-4 text-slate-500" />
|
||||
<span className="font-medium text-slate-700">
|
||||
Planning & Evaluatie
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pendingEvaluaties > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{pendingEvaluaties} gepland
|
||||
</Badge>
|
||||
)}
|
||||
{hasVeiligheidsplan && (
|
||||
<Badge variant="outline" className="text-xs text-orange-600 border-orange-300">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Veiligheidsplan
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<CardContent className="p-4 pt-0 space-y-4 border-t">
|
||||
{/* Behandelstructuur */}
|
||||
{behandelstructuur && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Behandelstructuur
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-slate-700">
|
||||
<Clock className="h-4 w-4 text-slate-400" />
|
||||
<span>{behandelstructuur.duur}</span>
|
||||
</div>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-slate-700">{behandelstructuur.frequentie}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-slate-700">
|
||||
{behandelstructuur.aantalSessies} sessies
|
||||
</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-slate-700">{behandelstructuur.vorm}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluatiemomenten */}
|
||||
{evaluatiemomenten && evaluatiemomenten.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Evaluatiemomenten
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{evaluatiemomenten.map((eval_) => (
|
||||
<EvaluatieItem key={eval_.id} evaluatie={eval_} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Veiligheidsplan */}
|
||||
{veiligheidsplan && (
|
||||
<VeiligheidsplanSection veiligheidsplan={veiligheidsplan} />
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface EvaluatieItemProps {
|
||||
evaluatie: Evaluatiemoment;
|
||||
}
|
||||
|
||||
function EvaluatieItem({ evaluatie }: EvaluatieItemProps) {
|
||||
const isCompleted = evaluatie.status === 'afgerond';
|
||||
const typeLabel =
|
||||
evaluatie.type === 'tussentijds'
|
||||
? 'Tussentijds'
|
||||
: evaluatie.type === 'eind'
|
||||
? 'Eind'
|
||||
: 'Crisis';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-md text-sm',
|
||||
isCompleted ? 'bg-green-50' : 'bg-slate-50'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-slate-300 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-700 truncate">
|
||||
Week {evaluatie.weekNumber}: {typeLabel}
|
||||
</p>
|
||||
{evaluatie.plannedDate && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(evaluatie.plannedDate).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VeiligheidsplanSectionProps {
|
||||
veiligheidsplan: Veiligheidsplan;
|
||||
}
|
||||
|
||||
function VeiligheidsplanSection({ veiligheidsplan }: VeiligheidsplanSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3 p-3 bg-orange-50 border border-orange-200 rounded-md">
|
||||
<div className="flex items-center gap-2 text-orange-700">
|
||||
<Shield className="h-4 w-4" />
|
||||
<h4 className="font-medium text-sm">Veiligheidsplan</h4>
|
||||
</div>
|
||||
|
||||
{/* Waarschuwingssignalen */}
|
||||
{veiligheidsplan.waarschuwingssignalen.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Waarschuwingssignalen
|
||||
</p>
|
||||
<ul className="text-sm text-orange-900 space-y-0.5">
|
||||
{veiligheidsplan.waarschuwingssignalen.map((signal, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>{signal}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coping strategieën */}
|
||||
{veiligheidsplan.copingStrategieen.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-orange-700">
|
||||
Coping strategieën
|
||||
</p>
|
||||
<ul className="text-sm text-orange-900 space-y-0.5">
|
||||
{veiligheidsplan.copingStrategieen.map((strategy, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<span className="text-orange-400">•</span>
|
||||
<span>{strategy}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contacten */}
|
||||
{veiligheidsplan.contacten.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
Noodcontacten
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{veiligheidsplan.contacten.map((contact, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm bg-white/50 rounded p-1.5 text-orange-900"
|
||||
>
|
||||
<p className="font-medium">{contact.naam}</p>
|
||||
<p className="text-xs text-orange-700">{contact.rol}</p>
|
||||
<p className="text-xs">{contact.telefoon}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// Behandelplan Views
|
||||
export { BehandelplanView } from './behandelplan-view';
|
||||
export { BehandelplanList } from './behandelplan-list';
|
||||
|
||||
// Editable Components
|
||||
export { EditableSection, ItemActions } from './editable-section';
|
||||
|
||||
// Section Forms
|
||||
export { BehandelstructuurForm } from './sections/behandelstructuur-form';
|
||||
export { GoalForm } from './sections/goal-form';
|
||||
export { InterventionForm } from './sections/intervention-form';
|
||||
@@ -1,77 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { Behandelstructuur } from '@/lib/types/behandelplan';
|
||||
|
||||
interface BehandelstructuurFormProps {
|
||||
initialData?: Behandelstructuur | null;
|
||||
onChange: (data: Behandelstructuur) => void;
|
||||
}
|
||||
|
||||
const DUUR_OPTIONS = ['4 weken', '6 weken', '8 weken', '10 weken', '12 weken', '16 weken', '24 weken'];
|
||||
const FREQUENTIE_OPTIONS = ['Wekelijks', 'Tweewekelijks', 'Maandelijks', '2x per week'];
|
||||
const VORM_OPTIONS = ['Individueel', 'Groep', 'Gezin', 'Paar', 'Online', 'Hybride'];
|
||||
|
||||
export function BehandelstructuurForm({ initialData, onChange }: BehandelstructuurFormProps) {
|
||||
const [data, setData] = useState<Behandelstructuur>(
|
||||
initialData || {
|
||||
duur: '8 weken',
|
||||
frequentie: 'Wekelijks',
|
||||
aantalSessies: 8,
|
||||
vorm: 'Individueel',
|
||||
}
|
||||
);
|
||||
|
||||
const handleChange = (field: keyof Behandelstructuur, value: string | number) => {
|
||||
const updated = { ...data, [field]: value };
|
||||
setData(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duur">Duur</Label>
|
||||
<Select value={data.duur} onValueChange={(v) => handleChange('duur', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer duur" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DUUR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequentie">Frequentie</Label>
|
||||
<Select value={data.frequentie} onValueChange={(v) => handleChange('frequentie', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer frequentie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FREQUENTIE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sessies">Aantal sessies</Label>
|
||||
<Input
|
||||
id="sessies"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={data.aantalSessies}
|
||||
onChange={(e) => handleChange('aantalSessies', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorm">Vorm</Label>
|
||||
<Select value={data.vorm} onValueChange={(v) => handleChange('vorm', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer vorm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VORM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { SmartGoal, GoalStatus } from '@/lib/types/behandelplan';
|
||||
import { LIFE_DOMAINS, LIFE_DOMAIN_META, type LifeDomain } from '@/lib/types/leefgebieden';
|
||||
|
||||
interface GoalFormProps {
|
||||
initialData?: SmartGoal | null;
|
||||
onChange: (data: SmartGoal) => void;
|
||||
}
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'hoog', label: 'Hoog' },
|
||||
{ value: 'middel', label: 'Middel' },
|
||||
{ value: 'laag', label: 'Laag' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS: { value: GoalStatus; label: string }[] = [
|
||||
{ value: 'niet_gestart', label: 'Niet gestart' },
|
||||
{ value: 'bezig', label: 'Bezig' },
|
||||
{ value: 'gehaald', label: 'Gehaald' },
|
||||
{ value: 'bijgesteld', label: 'Bijgesteld' },
|
||||
];
|
||||
|
||||
export function GoalForm({ initialData, onChange }: GoalFormProps) {
|
||||
const [data, setData] = useState<SmartGoal>(
|
||||
initialData || {
|
||||
id: crypto.randomUUID(),
|
||||
title: '',
|
||||
description: '',
|
||||
clientVersion: '',
|
||||
lifeDomain: 'dlv',
|
||||
priority: 'middel',
|
||||
measurability: '',
|
||||
timelineWeeks: 8,
|
||||
status: 'niet_gestart',
|
||||
progress: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const handleChange = <K extends keyof SmartGoal>(field: K, value: SmartGoal[K]) => {
|
||||
const updated = { ...data, [field]: value };
|
||||
setData(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={data.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
placeholder="Korte beschrijving van het doel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lifeDomain">Leefgebied</Label>
|
||||
<Select
|
||||
value={data.lifeDomain}
|
||||
onValueChange={(v) => handleChange('lifeDomain', v as LifeDomain)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecteer leefgebied" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LIFE_DOMAINS.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{LIFE_DOMAIN_META[domain].emoji} {LIFE_DOMAIN_META[domain].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">SMART Beschrijving</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={data.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="Specifiek, Meetbaar, Acceptabel, Realistisch, Tijdgebonden"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientVersion">Cliënt versie (B1-taal)</Label>
|
||||
<Textarea
|
||||
id="clientVersion"
|
||||
value={data.clientVersion}
|
||||
onChange={(e) => handleChange('clientVersion', e.target.value)}
|
||||
placeholder="Eenvoudige uitleg voor de cliënt"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Prioriteit</Label>
|
||||
<Select
|
||||
value={data.priority}
|
||||
onValueChange={(v) => handleChange('priority', v as 'hoog' | 'middel' | 'laag')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={data.status}
|
||||
onValueChange={(v) => handleChange('status', v as GoalStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timelineWeeks">Tijdlijn (weken)</Label>
|
||||
<Input
|
||||
id="timelineWeeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={data.timelineWeeks}
|
||||
onChange={(e) => handleChange('timelineWeeks', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="measurability">Meetbaarheid</Label>
|
||||
<Input
|
||||
id="measurability"
|
||||
value={data.measurability}
|
||||
onChange={(e) => handleChange('measurability', e.target.value)}
|
||||
placeholder="Hoe meten we vooruitgang?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>Voortgang</Label>
|
||||
<span className="text-sm text-slate-500">{data.progress}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[data.progress]}
|
||||
onValueChange={(v) => handleChange('progress', v[0])}
|
||||
max={100}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user