feat: Epic 1 completion - Marketing refactor met timeline en Bento Grid login

Epic 1 Stories (E1.S1 t/m E1.S6):

 E1.S1 - Verwijder EPD demo pagina
- Removed /epd route en credentials-box component
- Updated navigation: EPD Prototype → Login link
- Removed demo_users references from middleware en auth libs
- Cleaned up TypeScript errors in hero-section-2

 E1.S2 - Homepage vereenvoudigen
- Replaced lange manifesto (8000+ woorden) met statement section
- Added problem-solution-proof format (3 paragrafen)
- Removed comparison table, experiment CTA, manifesto content
- Timeline placeholder toegevoegd (completed in E1.S3)

 E1.S3 - Timeline component integreren
- Created BuildTimeline component (Aceternity UI pattern)
- Added timeline.json met 4 weken data (Week 1 completed)
- Features per week met icons, metrics, achievements
- Scroll-based animation met Framer Motion
- Responsive design (sticky titles, mobile/desktop layouts)

 E1.S4 - Timeline content structuur
- Structured JSON data in content/nl/timeline.json
- 15 Lucide icons voor feature types
- Week status badges (completed/in_progress/planned)
- Time savings display (< 5 sec vs 30 min)

 E1.S5 - Login pagina refactor
- Split-screen layout (60% features, 40% login)
- Teal gradient showcase met 4 feature cards
- Time savings badges per feature
- Responsive stack layout voor mobile
- Footer met AI Speedrun branding

 E1.S6 - Bento Grid showcase
- Replaced feature grid met Bento Grid layout
- Variable card sizes voor visual hierarchy (col-span-2, col-span-1)
- Dark slate background (from-slate-900)
- Gradient backgrounds per card (teal, amber, purple)
- Stats footer met 3 key metrics (90%+, < 5 sec, 4 weken)
- Hover animations en glassmorphism effects

🎨 Design System:
- Teal-first brand colors (#0D9488)
- Amber accents voor AI features (#F59E0B)
- Slate scale voor neutral colors
- WCAG AA compliant contrast

📦 Components:
- BuildTimeline (app/(marketing)/components)
- BentoGrid & BentoCard (components/ui)
- Button variants (components/ui)

📄 Content:
- timeline.json met volledige 4-weken data
- Features array per week
- Metrics en achievements tracking

🔧 Technical:
- Removed demo user scripts en migrations
- Updated middleware routes (removed /epd)
- TypeScript type fixes (hero-section-2, auth)
- Build succesvol: 13 routes generated

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-11-17 19:22:49 +01:00
parent 273c00f9e8
commit b7d19a8e1a
20 changed files with 2097 additions and 68 deletions

143
app/epd/clients/actions.ts Normal file
View File

@@ -0,0 +1,143 @@
'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 search = `%${filters.search}%`;
query = query.or(`first_name.ilike.${search},last_name.ilike.${search}`);
}
// 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 };
}

View File

@@ -0,0 +1,191 @@
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>
)
}

View File

@@ -0,0 +1,148 @@
'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);
} else {
await createClient(formData);
}
router.push('/epd/clients');
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>
);
}

View File

@@ -0,0 +1,54 @@
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>
);
}

View File

@@ -0,0 +1,256 @@
'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}
className="hover:bg-slate-50 transition-colors"
>
<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">
<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>
);
}

View File

@@ -0,0 +1,29 @@
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>
);
}

57
app/epd/clients/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { Suspense } from 'react';
import { Plus } from 'lucide-react';
import { getClients } from './actions';
import { ClientList } from './components/client-list';
import { ClientListSkeleton } from './components/client-list-skeleton';
import Link from 'next/link';
interface SearchParams {
search?: string;
sortBy?: 'name' | 'age' | 'created_at';
sortOrder?: 'asc' | 'desc';
}
export default async function ClientsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
return (
<div className="px-4 sm:px-6 lg:px-8 py-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Cliënten</h1>
<p className="text-sm text-slate-600 mt-1">
Beheer uw cliëntenbestand
</p>
</div>
<Link
href="/epd/clients/new"
className="inline-flex items-center gap-2 px-4 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"
>
<Plus className="h-4 w-4" />
<span>Nieuwe cliënt</span>
</Link>
</div>
</div>
{/* Client List with Suspense */}
<Suspense fallback={<ClientListSkeleton />}>
<ClientListWrapper searchParams={params} />
</Suspense>
</div>
);
}
async function ClientListWrapper({ searchParams }: { searchParams: SearchParams }) {
const clients = await getClients({
search: searchParams.search,
sortBy: searchParams.sortBy,
sortOrder: searchParams.sortOrder,
});
return <ClientList initialClients={clients} />;
}