diff --git a/AGENTS.md b/AGENTS.md index 56de78f..de2a39d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,39 +1,23 @@ # Repository Guidelines ## Project Structure & Module Organization -- `app/`: Next.js app router pages, routes, and layout shell; start edits in `app/page.tsx`. -- `components/`: Shared UI building blocks (React + Tailwind variants) used across routes. -- `lib/`: Utilities and integrations (e.g., Supabase client/types in `lib/supabase/`). -- `content/`, `docs/`: Markdown/docs assets; update here before hardcoding copies in `app/`. -- `public/`: Static assets served at `/`; keep optimized exports here. -- `scripts/`: Maintenance helpers (e.g., `scripts/test-contrast.ts`). -- `supabase/`: Database config and migrations (`supabase/migrations/*.sql`). +Next.js App Router pages and layouts live in `app/`; start UI work in `app/page.tsx`. Reusable presentation pieces sit in `components/`, while `lib/` holds utilities plus Supabase clients under `lib/supabase/`. Content-first assets live in `content/` and `docs/`; prefer editing Markdown there before copying into React components. Static files (images, fonts, icons) belong in `public/`. Database migrations are versioned under `supabase/migrations/*.sql`, and helper scripts reside in `scripts/`. ## Build, Test, and Development Commands -- `pnpm dev` (or `npm run dev`): Start local server at `http://localhost:3000` with hot reload. -- `pnpm build`: Production bundle; fails on type errors for app and server components. -- `pnpm start`: Run the built app locally. -- `pnpm lint`: Run ESLint with Next.js rules over `app/`, `components/`, `lib/`. -- `pnpm types:generate`: Regenerate Supabase TS types into `lib/supabase/types.ts` (requires project access). +- `pnpm dev`: launch the local dev server at `http://localhost:3000` with hot reload. +- `pnpm build`: produce a production bundle; fails on type or server-component errors. +- `pnpm start`: run the already built app locally for smoke checks. +- `pnpm lint`: execute ESLint with the Next.js preset across `app/`, `components/`, and `lib/`. +- `pnpm types:generate`: refresh Supabase typings into `lib/supabase/types.ts` once schema changes land. ## Coding Style & Naming Conventions -- Language: TypeScript + React Server/Client Components; follow Next.js app router patterns. -- Formatting: 2-space indentation; prefer single quotes where lint allows; keep imports sorted logically. -- Styling: Tailwind-first; compose variants via `class-variance-authority` and `tailwind-merge`. -- Naming: PascalCase for components, camelCase for helpers, `useX` for hooks, `types.ts` for shared types. -- Keep client components marked with `"use client"` when needed; avoid client code in server contexts. +Use TypeScript with 2-space indentation and prefer single quotes where lint allows. Rely on Tailwind classes for styling and compose variants via `class-variance-authority` plus `tailwind-merge`. Keep server components default; add `"use client"` only when interactivity requires it. Components are PascalCase, helpers camelCase, hooks prefixed with `use`, and shared type files named `types.ts`. Keep imports grouped logically and remove unused exports before committing. ## Testing Guidelines -- No dedicated automated test harness yet; rely on `pnpm lint` and manual flows in the browser. -- For data paths, validate Supabase connections with `lib/supabase/test-connection.ts` or local SQL migrations. -- Add route- or component-level checks (e.g., contrast checks via `scripts/test-contrast.ts`) before shipping UI tweaks. +There is no full suite yet, so lean on `pnpm lint` plus manual QA in the browser. Validate Supabase connectivity using `lib/supabase/test-connection.ts` whenever credentials or policies change. For UI contrast checks or theming tweaks, run `scripts/test-contrast.ts`. Document manual steps taken when validating PRs, especially for auth or data flows. ## Commit & Pull Request Guidelines -- Git history mixes short feature phrases and imperative summaries; keep new commits concise and action-led (e.g., `feat: add login hero` or `fix: align tab spacing`). -- Prefer scoped, single-purpose commits; include why when the change is non-obvious. -- PRs: describe user-facing impact, screenshots for UI changes, reproduction steps for bugs, and link issues/tasks when available. -- Note any Supabase schema changes and the migration file touched; mention if `types:generate` was rerun. +Commit subjects are short, action-led (e.g., `feat: add login hero`, `fix: align tab spacing`). Keep each commit scoped to a single concern and mention why when behavior is non-obvious. PRs should describe the user-facing impact, include reproduction steps for bug fixes, and attach screenshots for UI changes. Note any Supabase migration touched and whether `pnpm types:generate` was executed. ## Security & Configuration Tips -- Environment: keep secrets in `.env.local`; never commit them. Required keys follow Next.js/Supabase conventions (`NEXT_PUBLIC_` for client-safe values). -- When testing auth/DB flows, ensure Supabase policies (`supabase/migrations/*_policies.sql`) are applied and reviewed. +Store secrets only in `.env.local`, following Next.js conventions (`NEXT_PUBLIC_` for safe client variables). When altering Supabase tables or policies, apply updates via `supabase/migrations/` and review Row Level Security before pushing. Delete hard-coded credentials from code and logs before submitting changes. diff --git a/app/api/deepgram/token/route.ts b/app/api/deepgram/token/route.ts new file mode 100644 index 0000000..befacf4 --- /dev/null +++ b/app/api/deepgram/token/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import { createClient as createSupabaseClient } from '@/lib/auth/server' +import { createClient as createDeepgramClient } from '@deepgram/sdk' + +const MAX_TOKENS_PER_HOUR = 10 +const TOKEN_TTL_SECONDS = 3600 +const HOUR_IN_MS = 60 * 60 * 1000 + +type RateLimitEntry = { + count: number + resetAt: number +} + +type RateLimitResult = { + allowed: boolean + remaining: number + resetAt: number +} + +const rateLimitStore = new Map() + +function consumeRateLimit(userId: string): RateLimitResult { + const now = Date.now() + const entry = rateLimitStore.get(userId) + + if (!entry || now >= entry.resetAt) { + const resetAt = now + HOUR_IN_MS + rateLimitStore.set(userId, { count: 1, resetAt }) + return { + allowed: true, + remaining: MAX_TOKENS_PER_HOUR - 1, + resetAt, + } + } + + if (entry.count >= MAX_TOKENS_PER_HOUR) { + return { + allowed: false, + remaining: 0, + resetAt: entry.resetAt, + } + } + + entry.count += 1 + + return { + allowed: true, + remaining: MAX_TOKENS_PER_HOUR - entry.count, + resetAt: entry.resetAt, + } +} + +function buildRateLimitHeaders(result: RateLimitResult) { + return { + 'X-RateLimit-Limit': `${MAX_TOKENS_PER_HOUR}`, + 'X-RateLimit-Remaining': `${Math.max(result.remaining, 0)}`, + 'X-RateLimit-Reset': `${Math.floor(result.resetAt / 1000)}`, + } +} + +export async function POST() { + try { + const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true' + let userId = 'demo-user' + + // Auth check (skip in development/demo mode) + if (!skipAuthCheck) { + const supabase = await createSupabaseClient() + const { data: authData, error: authError } = await supabase.auth.getUser() + + if (authError) { + console.error('Deepgram token: auth error', authError) + return NextResponse.json( + { error: 'Kon sessie niet ophalen' }, + { status: 500 } + ) + } + + if (!authData?.user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }) + } + + userId = authData.user.id + } else { + console.log('πŸ”“ Deepgram token: Auth check overgeslagen (demo mode)') + } + + const apiKey = process.env.DEEPGRAM_API_KEY + + if (!apiKey) { + return NextResponse.json( + { error: 'Deepgram API key ontbreekt in server configuratie' }, + { status: 500 } + ) + } + + const rateLimit = consumeRateLimit(userId) + const headers = buildRateLimitHeaders(rateLimit) + + if (!rateLimit.allowed) { + const retryAfterSeconds = Math.max( + 0, + Math.ceil((rateLimit.resetAt - Date.now()) / 1000) + ) + return NextResponse.json( + { error: 'Rate limit overschreden. Max 10 tokens per uur.' }, + { + status: 429, + headers: { + ...headers, + 'Retry-After': `${retryAfterSeconds}`, + }, + } + ) + } + + const deepgram = createDeepgramClient(apiKey) + const tokenResponse = await deepgram.auth.grantToken({ + ttl_seconds: TOKEN_TTL_SECONDS, + }) + + if (!tokenResponse.result) { + console.error('Deepgram token: API error', tokenResponse.error) + return NextResponse.json( + { error: 'Genereren van tijdelijk token mislukt' }, + { status: 502, headers } + ) + } + + return NextResponse.json( + { + token: tokenResponse.result.access_token, + expiresIn: tokenResponse.result.expires_in, + }, + { headers } + ) + } catch (error) { + console.error('Deepgram token: onverwachte fout', error) + return NextResponse.json( + { error: 'Onverwachte fout bij genereren token' }, + { status: 500 } + ) + } +} diff --git a/app/epd/patients/[id]/rapportage/actions.ts b/app/epd/patients/[id]/rapportage/actions.ts index 7a168ae..9bf898e 100644 --- a/app/epd/patients/[id]/rapportage/actions.ts +++ b/app/epd/patients/[id]/rapportage/actions.ts @@ -55,6 +55,34 @@ export async function createReport( return response.json(); } +export async function updateReport( + patientId: string, + reportId: string, + input: { content: string } +): Promise { + const baseUrl = getBaseUrl(); + const url = `${baseUrl}/api/reports/${reportId}`; + + const response = await authFetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + if (!response.ok) { + if (response.status === 401) { + redirect('/login'); + } + const error = await response.json().catch(() => ({})); + throw new Error(error.error || 'Bijwerken mislukt'); + } + + revalidatePath(`/epd/patients/${patientId}/rapportage`); + return response.json(); +} + export async function deleteReport(patientId: string, reportId: string) { const baseUrl = getBaseUrl(); const url = `${baseUrl}/api/reports/${reportId}`; diff --git a/app/epd/patients/[id]/rapportage/components/quick-actions.tsx b/app/epd/patients/[id]/rapportage/components/quick-actions.tsx new file mode 100644 index 0000000..b8aa262 --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/quick-actions.tsx @@ -0,0 +1,83 @@ +'use client' + +import { FileText, ClipboardList } from 'lucide-react' +import { cn } from '@/lib/utils' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type ReportType = 'vrije_notitie' | 'behandeladvies' + +interface QuickAction { + id: string + label: string + icon: typeof FileText + type: ReportType +} + +export interface QuickActionsProps { + /** Callback wanneer een type wordt geselecteerd */ + onSelectType: (type: ReportType) => void + /** Huidige geselecteerde type */ + selectedType?: ReportType + /** Disabled state */ + disabled?: boolean + /** Extra CSS classes */ + className?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const QUICK_ACTIONS: QuickAction[] = [ + { id: 'vrije-notitie', label: '+ Vrije notitie', icon: FileText, type: 'vrije_notitie' }, + { id: 'behandeladvies', label: '+ Behandeladvies', icon: ClipboardList, type: 'behandeladvies' }, +] + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + +export function QuickActions({ + onSelectType, + selectedType, + disabled = false, + className, +}: QuickActionsProps) { + const handleSelect = (type: ReportType) => { + onSelectType(type) + } + + return ( +
+ Nieuwe rapportage: + + {QUICK_ACTIONS.map((action) => { + const Icon = action.icon + const isActive = selectedType === action.type + + return ( + + ) + })} +
+ ) +} diff --git a/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx b/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx index c7890ec..67dc14a 100644 --- a/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx +++ b/app/epd/patients/[id]/rapportage/components/rapportage-workspace.tsx @@ -1,324 +1,180 @@ -'use client'; +'use client' -import type { ReactNode } from 'react'; -import { useMemo, useState, useEffect } from 'react'; -import { Search, Filter, Sparkles, Timer } from 'lucide-react'; -import type { Report } from '@/lib/types/report'; -import { ReportTimeline } from './report-timeline'; -import { ReportComposer } from './report-composer'; -import { useMediaQuery } from '@/hooks/use-media-query'; +import { useState, useCallback } from 'react' +import { LayoutList } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Report } from '@/lib/types/report' +import { useMediaQuery } from '@/hooks/use-media-query' +import { ReportComposer } from './report-composer' +import { QuickActions, type ReportType } from './quick-actions' +import { TimelineSidebar, TimelineSidebarOverlay } from './timeline-sidebar' +import { ReportViewEditModal } from './report-view-edit-modal' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── interface RapportageWorkspaceProps { - patientId: string; - patientName: string; - initialReports: Report[]; + patientId: string + patientName: string + initialReports: Report[] } -const TYPE_LABELS: Record = { - behandeladvies: 'Behandeladvies', - vrije_notitie: 'Vrije notitie', -}; +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── -export function RapportageWorkspace({ patientId, patientName, initialReports }: RapportageWorkspaceProps) { - const [reports, setReports] = useState(initialReports); - const [search, setSearch] = useState(''); - const [typeFilter, setTypeFilter] = useState<'all' | keyof typeof TYPE_LABELS>('all'); - const [selectedReportId, setSelectedReportId] = useState(null); - const [authorFilter, setAuthorFilter] = useState<'all' | string>('all'); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - const [aiFilter, setAiFilter] = useState<'all' | 'ai' | 'manual'>('all'); - const isMobile = useMediaQuery('(max-width: 1023px)'); - const [activeTab, setActiveTab] = useState<'timeline' | 'composer'>('timeline'); +export function RapportageWorkspace({ + patientId, + patientName, + initialReports, +}: RapportageWorkspaceProps) { + const [reports, setReports] = useState(initialReports) + const [selectedType, setSelectedType] = useState('vrije_notitie') + const [isTimelineOpen, setIsTimelineOpen] = useState(false) + const [selectedReport, setSelectedReport] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [duplicateContent, setDuplicateContent] = useState(null) + const isMobile = useMediaQuery('(max-width: 1023px)') - useEffect(() => { - if (!isMobile) { - setActiveTab('timeline'); + // Handlers + const handleReportCreated = useCallback((report: Report) => { + setReports((prev) => [report, ...prev]) + }, []) + + const handleSelectReport = useCallback((report: Report) => { + setSelectedReport(report) + setIsModalOpen(true) + }, []) + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false) + // Bewaar selectedReport voor active state in timeline + }, []) + + const handleReportUpdated = useCallback((updatedReport: Report) => { + setReports((prev) => + prev.map((r) => (r.id === updatedReport.id ? updatedReport : r)) + ) + setSelectedReport(updatedReport) + }, []) + + const handleReportDeleted = useCallback((reportId: string) => { + setReports((prev) => prev.filter((r) => r.id !== reportId)) + if (selectedReport?.id === reportId) { + setSelectedReport(null) } - }, [isMobile]); + }, [selectedReport]) - const authorOptions = useMemo(() => { - const unique = Array.from(new Set(reports.map((report) => report.created_by).filter(Boolean))); - return unique as string[]; - }, [reports]); + const handleDuplicate = useCallback((content: string) => { + setDuplicateContent(content) + setIsTimelineOpen(false) // Sluit timeline zodat editor zichtbaar is + }, []) - const filteredReports = useMemo(() => { - return reports.filter((report) => { - const matchesType = typeFilter === 'all' || report.type === typeFilter; - const matchesSearch = - !search || - report.content.toLowerCase().includes(search.toLowerCase()) || - report.ai_reasoning?.toLowerCase().includes(search.toLowerCase()); - const matchesAuthor = authorFilter === 'all' || report.created_by === authorFilter; - const createdAt = report.created_at ? new Date(report.created_at) : null; - const matchesFrom = !dateFrom || (createdAt && createdAt >= new Date(dateFrom)); - const matchesTo = !dateTo || (createdAt && createdAt <= new Date(`${dateTo}T23:59:59`)); - const matchesAI = - aiFilter === 'all' || - (aiFilter === 'ai' ? report.ai_confidence !== null : report.ai_confidence === null); + const handleTypeSelect = useCallback((type: ReportType) => { + setSelectedType(type) + }, []) - return matchesType && matchesSearch && matchesAuthor && matchesFrom && matchesTo && matchesAI; - }); - }, [reports, search, typeFilter, authorFilter, dateFrom, dateTo, aiFilter]); - - const selectedReport = useMemo( - () => reports.find((report) => report.id === selectedReportId) ?? null, - [reports, selectedReportId] - ); - - const totalReports = reports.length; - const aiReports = reports.filter((report) => report.ai_confidence !== null).length; - const latestReport = reports[0]; - const latestDate = latestReport?.created_at - ? new Date(latestReport.created_at).toLocaleString('nl-NL') - : null; - - const handleReportCreated = (report: Report) => { - setReports((prev) => [report, ...prev]); - setSelectedReportId(report.id); - }; - - const handleDeleteSuccess = (reportId: string) => { - setReports((prev) => prev.filter((report) => report.id !== reportId)); - if (selectedReportId === reportId) { - setSelectedReportId(null); - } - }; - - const resetFilters = () => { - setSearch(''); - setTypeFilter('all'); - setAuthorFilter('all'); - setDateFrom(''); - setDateTo(''); - setAiFilter('all'); - }; + const toggleTimeline = useCallback(() => { + setIsTimelineOpen((prev) => !prev) + }, []) return ( -
-
-

Universele rapportage

-

Tijdlijn en notities

-

- Alle behandeladviezen en vrije notities voor {patientName} op één plek. -

+
+ {/* Header */} +
+
+
+
+

+ Rapportage +

+

+ {patientName} +

+
+ + {/* Timeline toggle button */} + +
+
-
- } - label="Totaal rapportages" - value={totalReports.toString()} - helper={totalReports > 0 ? 'Inclusief AI-notities' : 'Nog geen rapportages'} - /> - } - label="AI classificaties" - value={aiReports.toString()} - helper="Aantal rapportages met AI-bijdrage" - /> - } - label="Laatste activiteit" - value={latestDate ?? 'β€”'} - helper={latestDate ? 'Recentste rapportage' : 'Nog geen activiteit'} - /> -
- - {isMobile && ( -
- - -
- )} - -
- {(!isMobile || activeTab === 'timeline') && ( - + {/* Main content area */} +
+
+ {/* Quick Actions */} +
+ +
- {(!isMobile || activeTab === 'composer') && ( -
- {isMobile && activeTab === 'composer' && ( - - )} + {/* Editor */} +
setDuplicateContent(null)} /> -
- )} -
-
- ); -} + +
+ -function StatCard({ - icon, - label, - value, - helper, -}: { - icon: ReactNode; - label: string; - value: string; - helper: string; -}) { - return ( -
-
- {label} -
{icon}
-
-

{value}

-

{helper}

-
- ); -} + {/* Timeline Sidebar */} + setIsTimelineOpen(false)} + /> + + setIsTimelineOpen(false)} + reports={reports} + onSelectReport={handleSelectReport} + activeReportId={selectedReport?.id} + /> -function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { - return ( - - ); + {/* View/Edit Modal */} + +
+ ) } diff --git a/app/epd/patients/[id]/rapportage/components/report-timeline.tsx b/app/epd/patients/[id]/rapportage/components/report-timeline.tsx index 1c7c136..590d505 100644 --- a/app/epd/patients/[id]/rapportage/components/report-timeline.tsx +++ b/app/epd/patients/[id]/rapportage/components/report-timeline.tsx @@ -1,96 +1,254 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { FilePlus2 } from 'lucide-react'; +import { FilePlus2, Search, Filter, FileText, ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; import type { Report } from '@/lib/types/report'; -import { deleteReport } from '../actions'; -import { ReportCard } from './report-card'; -import { toast } from '@/hooks/use-toast'; +import { TimelineCard } from './timeline-card'; interface ReportTimelineProps { reports: Report[]; - patientId: string; - selectedReportId?: string | null; - onSelect?: (report: Report) => void; - onDeleteSuccess?: (reportId: string) => void; + onSelectReport: (report: Report) => void; + activeReportId?: string | null; + className?: string; } +const TYPE_LABELS: Record = { + behandeladvies: 'Behandeladvies', + vrije_notitie: 'Vrije notitie', + intake_verslag: 'Intake verslag', + behandelplan: 'Behandelplan', +}; + +const FILTER_THRESHOLD = 10; const INITIAL_VISIBLE = 20; export function ReportTimeline({ reports, - patientId, - selectedReportId, - onSelect, - onDeleteSuccess, + onSelectReport, + activeReportId, + className, }: ReportTimelineProps) { - const [deletingId, setDeletingId] = useState(null); + const [search, setSearch] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [typeFilter, setTypeFilter] = useState('all'); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE); useEffect(() => { setVisibleCount(INITIAL_VISIBLE); }, [reports]); - const visibleReports = useMemo(() => reports.slice(0, visibleCount), [reports, visibleCount]); + // Gefilterde rapportages + const filteredReports = useMemo(() => { + return reports.filter((report) => { + const matchesSearch = + !search || + report.content.toLowerCase().includes(search.toLowerCase()) || + TYPE_LABELS[report.type]?.toLowerCase().includes(search.toLowerCase()); - const handleDelete = async (reportId: string) => { - setDeletingId(reportId); - try { - await deleteReport(patientId, reportId); - onDeleteSuccess?.(reportId); - toast({ - title: 'Rapportage verwijderd', - description: 'De rapportage is verwijderd uit de tijdlijn.', - }); - } catch (error) { - console.error('Failed to delete report', error); - toast({ - variant: 'destructive', - title: 'Verwijderen mislukt', - description: - error instanceof Error ? error.message : 'Probeer het later opnieuw.', - }); - } finally { - setDeletingId(null); - } - }; + const matchesType = typeFilter === 'all' || report.type === typeFilter; - if (reports.length === 0) { - return ( -
-
- -
-

Nog geen rapportages

-

- Klik op “Nieuwe rapportage” in de header om de eerste rapportage vast te leggen. -

-
- ); - } + return matchesSearch && matchesType; + }); + }, [reports, search, typeFilter]); - const hasMore = reports.length > visibleCount; + const visibleReports = useMemo( + () => filteredReports.slice(0, visibleCount), + [filteredReports, visibleCount] + ); + + const showFilterSection = reports.length > FILTER_THRESHOLD; + const hasMore = filteredReports.length > visibleCount; + + const availableTypes = useMemo(() => { + const types = new Set(reports.map((r) => r.type)); + return Array.from(types); + }, [reports]); return ( -
- {visibleReports.map((report) => ( - handleDelete(report.id)} - isDeleting={deletingId === report.id} - onSelect={() => onSelect?.(report)} - isSelected={selectedReportId === report.id} - /> - ))} - {hasMore && ( - +
+ {/* Header */} +
+
+
+ +

Tijdlijn

+ + {filteredReports.length} + +
+ + {showFilterSection && ( + + )} +
+ + {/* Search Bar */} +
+ + setSearch(e.target.value)} + placeholder="Zoek in rapportages..." + className="w-full pl-10 pr-4 py-2.5 text-sm border border-slate-200 rounded-lg bg-slate-50 focus:bg-white focus:border-emerald-300 focus:ring-2 focus:ring-emerald-100 focus:outline-none transition-all" + /> +
+ + {/* Filters Panel */} + {showFilters && showFilterSection && ( +
+
+ + + {(typeFilter !== 'all' || search) && ( + + )} +
+
+ )} +
+ + {/* Timeline Content */} +
+ {filteredReports.length === 0 ? ( +
+
+
+ +
+

+ {reports.length === 0 + ? 'Nog geen rapportages' + : 'Geen resultaten gevonden'} +

+

+ {reports.length === 0 + ? 'Maak je eerste rapportage om hier de tijdlijn te zien.' + : 'Probeer een andere zoekopdracht of pas de filters aan.'} +

+ {search && ( + + )} +
+
+ ) : ( +
+ {/* Group by date */} + {visibleReports.map((report, index) => { + const prevReport = index > 0 ? visibleReports[index - 1] : null; + const currentDate = new Date( + report.created_at + ).toLocaleDateString('nl-NL', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const prevDate = prevReport + ? new Date(prevReport.created_at).toLocaleDateString('nl-NL', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : null; + + const showDateDivider = currentDate !== prevDate; + + return ( +
+ {showDateDivider && ( +
+
+ + {currentDate} + +
+
+ )} + onSelectReport(report)} + isActive={activeReportId === report.id} + /> +
+ ); + })} + + {/* Load More */} + {hasMore && ( + + )} +
+ )} +
+ + {/* Footer Stats */} + {filteredReports.length > 0 && ( +
+
+ + {visibleReports.length} van {filteredReports.length} rapportages + + {(search || typeFilter !== 'all') && ( + Gefilterd + )} +
+
)}
); diff --git a/app/epd/patients/[id]/rapportage/components/timeline-card.tsx b/app/epd/patients/[id]/rapportage/components/timeline-card.tsx new file mode 100644 index 0000000..4371ff5 --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/timeline-card.tsx @@ -0,0 +1,147 @@ +'use client' + +import { FilePenLine, FileText } from 'lucide-react' +import { format, formatDistanceToNow } from 'date-fns' +import { nl } from 'date-fns/locale' +import { cn } from '@/lib/utils' +import type { Report } from '@/lib/types/report' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface TimelineCardProps { + report: Report + onView: () => void + isActive?: boolean +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const TYPE_META = { + behandeladvies: { + label: 'Behandeladvies', + icon: FilePenLine, + iconBg: 'bg-teal-50 text-teal-600', + }, + vrije_notitie: { + label: 'Vrije notitie', + icon: FileText, + iconBg: 'bg-slate-100 text-slate-600', + }, + intake_verslag: { + label: 'Intake verslag', + icon: FileText, + iconBg: 'bg-blue-50 text-blue-600', + }, + behandelplan: { + label: 'Behandelplan', + icon: FilePenLine, + iconBg: 'bg-purple-50 text-purple-600', + }, +} as const + +// ───────────────────────────────────────────────────────────────────────────── +// Helper Functions +// ───────────────────────────────────────────────────────────────────────────── + +function getPreview(content: string, maxLines = 2): string { + if (!content) return '' + + // Strip HTML tags als er HTML in zit + const stripped = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() + + // Neem eerste N regels of max 100 karakters + const lines = stripped.split(/[.!?]\s+/).slice(0, maxLines) + const preview = lines.join('. ') + + if (preview.length > 120) { + return preview.slice(0, 120) + '...' + } + + return preview + (stripped.length > preview.length ? '...' : '') +} + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + +export function TimelineCard({ report, onView, isActive = false }: TimelineCardProps) { + const meta = TYPE_META[report.type as keyof typeof TYPE_META] ?? TYPE_META.vrije_notitie + const Icon = meta.icon + + const createdAt = report.created_at ? new Date(report.created_at) : null + const dateFormatted = createdAt + ? format(createdAt, "d-MM, HH:mm", { locale: nl }) + : '' + const relativeTime = createdAt + ? formatDistanceToNow(createdAt, { addSuffix: false, locale: nl }) + : '' + + const preview = getPreview(report.content) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onView() + } + }} + > + {/* Header */} +
+
+ +
+
+

+ {meta.label} +

+

+ {dateFormatted} + {relativeTime && ( + + Β· {relativeTime} geleden + + )} +

+
+
+ + {/* Preview */} + {preview && ( +

+ {preview} +

+ )} + + {/* Action */} +
+ +
+
+ ) +} + diff --git a/app/epd/patients/[id]/rapportage/components/timeline-sidebar.tsx b/app/epd/patients/[id]/rapportage/components/timeline-sidebar.tsx new file mode 100644 index 0000000..5d8e7c2 --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/timeline-sidebar.tsx @@ -0,0 +1,219 @@ +'use client' + +import { useState, useMemo } from 'react' +import { X, Search, ChevronDown, FileText } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Report } from '@/lib/types/report' +import { TimelineCard } from './timeline-card' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface TimelineSidebarProps { + /** Of de sidebar open is */ + isOpen: boolean + /** Callback om sidebar te sluiten */ + onClose: () => void + /** Lijst van rapportages */ + reports: Report[] + /** Callback wanneer een rapport wordt geselecteerd */ + onSelectReport: (report: Report) => void + /** ID van actief rapport (in modal) */ + activeReportId?: string | null + /** Extra CSS classes */ + className?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const TYPE_LABELS: Record = { + behandeladvies: 'Behandeladvies', + vrije_notitie: 'Vrije notitie', + intake_verslag: 'Intake verslag', + behandelplan: 'Behandelplan', +} + +const FILTER_THRESHOLD = 10 // Toon filters alleen bij meer dan N items + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + +export function TimelineSidebar({ + isOpen, + onClose, + reports, + onSelectReport, + activeReportId, + className, +}: TimelineSidebarProps) { + const [search, setSearch] = useState('') + const [showFilters, setShowFilters] = useState(false) + const [typeFilter, setTypeFilter] = useState('all') + + // Gefilterde rapportages + const filteredReports = useMemo(() => { + return reports.filter((report) => { + const matchesSearch = !search || + report.content.toLowerCase().includes(search.toLowerCase()) || + TYPE_LABELS[report.type]?.toLowerCase().includes(search.toLowerCase()) + + const matchesType = typeFilter === 'all' || report.type === typeFilter + + return matchesSearch && matchesType + }) + }, [reports, search, typeFilter]) + + // Toon filters alleen als er meer dan threshold items zijn + const showFilterSection = reports.length > FILTER_THRESHOLD + + // Unieke types voor filter dropdown + const availableTypes = useMemo(() => { + const types = new Set(reports.map(r => r.type)) + return Array.from(types) + }, [reports]) + + return ( + + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Overlay Component (voor achtergrond dimmen) +// ───────────────────────────────────────────────────────────────────────────── + +export function TimelineSidebarOverlay({ + isOpen, + onClose, +}: { + isOpen: boolean + onClose: () => void +}) { + if (!isOpen) return null + + return ( +