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:
143
app/epd/clients/actions.ts
Normal file
143
app/epd/clients/actions.ts
Normal 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 };
|
||||
}
|
||||
191
app/epd/clients/coming-soon-backup.tsx
Normal file
191
app/epd/clients/coming-soon-backup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
app/epd/clients/components/client-form.tsx
Normal file
148
app/epd/clients/components/client-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/epd/clients/components/client-list-skeleton.tsx
Normal file
54
app/epd/clients/components/client-list-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
app/epd/clients/components/client-list.tsx
Normal file
256
app/epd/clients/components/client-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
app/epd/clients/new/page.tsx
Normal file
29
app/epd/clients/new/page.tsx
Normal 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
57
app/epd/clients/page.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user