// Klantportaal — voertuigen, afspraken, APK-reminder, facturen, profiel function Portaal({ user, onNav, onLogout }) { const [tab, setTab] = React.useState("overzicht"); const [me, setMe] = React.useState(user); const [vehicles, setVehicles] = React.useState([]); const [appointments, setAppointments] = React.useState([]); const [loading, setLoading] = React.useState(true); const [toast, setToast] = React.useState(null); // { type, msg } const [showAddVehicle, setShowAddVehicle] = React.useState(false); const [editVehicle, setEditVehicle] = React.useState(null); // vehicle obj const [editProfile, setEditProfile] = React.useState(false); const showToast = (msg, type = "success") => { setToast({ msg, type }); setTimeout(() => setToast(null), 4000); }; // Refresh helpers const refreshAll = React.useCallback(async () => { setLoading(true); try { const [meData, apptsData] = await Promise.all([ window.api.auth.me().catch(() => ({ user: null, vehicles: [] })), window.api.appointments.list().catch(() => ({ appointments: [] })), ]); if (meData.user) setMe(meData.user); setVehicles(meData.vehicles || []); setAppointments(apptsData.appointments || []); } finally { setLoading(false); } }, []); React.useEffect(() => { if (!window.api) return; refreshAll(); }, [refreshAll]); // Voertuig-CRUD callbacks const onVehicleAdded = () => { setShowAddVehicle(false); refreshAll(); showToast("Voertuig toegevoegd"); }; const onVehicleUpdated = () => { setEditVehicle(null); refreshAll(); showToast("Wijzigingen opgeslagen"); }; const onVehicleDeleted = async (id) => { if (!confirm("Voertuig verwijderen uit uw account?")) return; try { await window.api.vehicles.remove(id); refreshAll(); showToast("Voertuig verwijderd"); } catch (e) { showToast(e.message || "Kon voertuig niet verwijderen", "error"); } }; // Afspraken acties const onCancelAppointment = async (id) => { if (!confirm("Afspraak annuleren? Dit kan niet ongedaan gemaakt worden.")) return; try { await window.api.appointments.cancel(id, "klant geannuleerd via portaal"); refreshAll(); showToast("Afspraak geannuleerd"); } catch (e) { showToast(e.message || "Kon afspraak niet annuleren", "error"); } }; // Helpers const fmtDate = (iso) => new Date(iso).toLocaleDateString("nl-NL", { day: "numeric", month: "long", year: "numeric" }); const fmtTime = (iso) => new Date(iso).toLocaleTimeString("nl-NL", { hour: "2-digit", minute: "2-digit" }); const today = new Date(); // Voertuigen normaliseren met APK-info const normalizedVehicles = vehicles.map((v, i) => { const apkDate = v.apk_expiry ? new Date(v.apk_expiry) : null; const apkDays = apkDate ? Math.ceil((apkDate - today) / (1000 * 60 * 60 * 24)) : null; return { id: v.id, plate: v.license_plate, merk: [v.make, v.model].filter(Boolean).join(" ") || "Onbekend", jaar: v.year, mileage: v.mileage, km: v.mileage ? new Intl.NumberFormat("nl-NL").format(v.mileage) : "—", fuel: v.fuel_type, apk: apkDate ? apkDate.toLocaleDateString("nl-NL") : "—", apkDays, apkExpiry: v.apk_expiry, primary: i === 0, }; }); const upcoming = appointments .filter(a => ["booked", "arrived", "in_progress"].includes(a.status)) .filter(a => new Date(a.start_at) >= new Date(new Date().toDateString())) .sort((a, b) => new Date(a.start_at) - new Date(b.start_at)) .map(a => ({ id: a.id, raw_start: a.start_at, date: fmtDate(a.start_at), time: fmtTime(a.start_at), service_id: a.service_id, service: a.service_name, service_slug: a.service_slug, plate: a.license_plate, vehicle: a.vehicle, status: a.status, })); const history = appointments .filter(a => ["done", "picked", "cancelled"].includes(a.status)) .map(a => ({ id: a.id, raw_start: a.start_at, date: fmtDate(a.start_at), service: a.service_name, service_slug: a.service_slug, plate: a.license_plate, total: a.price !== null ? `€ ${a.price.toFixed(2).replace(".", ",")}` : "op offerte", status: a.status, invoice: `${new Date(a.start_at).getFullYear()}-${String(a.id).padStart(4, "0")}`, })); const apkSoon = normalizedVehicles .filter(v => v.apkDays !== null && v.apkDays < 60) .sort((a, b) => a.apkDays - b.apkDays)[0]; if (loading) { return (
Bezig met laden…
); } return (
{/* Toast */} {toast && (
{toast.msg}
)} {/* Header band */}
Klantportaal

Welkom terug, {me.name.split(" ")[0]}.

{normalizedVehicles.length} voertuig{normalizedVehicles.length === 1 ? "" : "en"} · {upcoming.length} aankomende afspra{upcoming.length === 1 ? "ak" : "ken"}

{/* Tabs */}
{[ { id: "overzicht", label: "Overzicht" }, { id: "voertuigen", label: "Mijn voertuigen" }, { id: "afspraken", label: "Afspraken" }, { id: "facturen", label: "Facturen" }, { id: "profiel", label: "Profiel" }, ].map(t => ( ))}
{/* Empty state — geen voertuigen, hard pushen om er een toe te voegen */} {normalizedVehicles.length === 0 && tab !== "profiel" ? ( setShowAddVehicle(true)} onNav={onNav}/> ) : ( <> {tab === "overzicht" && setShowAddVehicle(true)} onEditVehicle={setEditVehicle} onCancel={onCancelAppointment} />} {tab === "voertuigen" && setShowAddVehicle(true)} onEdit={setEditVehicle} onDelete={onVehicleDeleted} onNav={onNav} />} {tab === "afspraken" && } {tab === "facturen" && h.status === "done" || h.status === "picked")}/>} )} {tab === "profiel" && { setMe(u); showToast("Profiel bijgewerkt"); }} showToast={showToast}/>}
{/* Modals */} {showAddVehicle && setShowAddVehicle(false)} onAdded={onVehicleAdded}/>} {editVehicle && setEditVehicle(null)} onSaved={onVehicleUpdated}/>}
); } // ============================================================ // Empty state — eerste-bezoek welkomstkaart // ============================================================ function EmptyState({ onAdd, onNav }) { return (

Welkom bij Garage DéDé

Voeg uw eerste voertuig toe — wij halen merk, model en APK-vervaldatum automatisch op via uw kenteken.

); } // ============================================================ // Overzicht-tab — APK banner, volgende afspraak, recente bezoeken // ============================================================ function Overzicht({ vehicles, upcoming, history, apkSoon, onNav, onAddVehicle, onEditVehicle, onCancel }) { const next = upcoming[0]; return (
{/* APK-reminder card */} {apkSoon && (
APK herinnering

{apkSoon.apkDays < 0 ? <>Uw APK is {Math.abs(apkSoon.apkDays)} dagen verlopen. : apkSoon.apkDays === 0 ? <>Uw APK verloopt vandaag. : <>Uw APK verloopt over {apkSoon.apkDays} dagen. }

Plan nu in zodat u kunt kiezen wanneer het u uitkomt — een APK twee maanden vooraf laten doen betekent géén verlies van keuringsdagen.

{apkSoon.merk} · APK tot {apkSoon.apk}
)} {/* Volgende afspraak */}
Volgende afspraak
{next ? ( onCancel(next.id)}/> ) : (

Geen aankomende afspraken.

)}
{/* Recente bezoeken */} {history.length > 0 && (
Recente bezoeken
{history.slice(0, 3).map((h, i) => (
{h.date}
{h.service}
{h.plate}
{h.total}
))}
)}
{/* Rechter kolom: voertuigen + tip */}
Mijn voertuigen
{vehicles.map((v) => ( ))}
💡 Wist u dat

U mag uw APK twee maanden voor de vervaldatum laten doen — zonder dat u dagen verliest. De nieuwe vervaldatum blijft hetzelfde.

); } function NextAppointmentCard({ appt, onCancel }) { const d = new Date(appt.raw_start); const months = ["JAN","FEB","MRT","APR","MEI","JUN","JUL","AUG","SEP","OKT","NOV","DEC"]; const days = ["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag"]; return (
{months[d.getMonth()]}
{d.getDate()}
{days[d.getDay()]}
{appt.service}
{appt.time} · Achterasweg 10-A, Vijfhuizen
); } function StatusPill({ status }) { const map = { booked: { label: "Gepland", color: "var(--accent)" }, arrived: { label: "Aangekomen", color: "var(--accent)" }, in_progress: { label: "In behandeling", color: "var(--accent)" }, done: { label: "Klaar", color: "var(--success, #10b981)" }, picked: { label: "Opgehaald", color: "var(--success, #10b981)" }, cancelled: { label: "Geannuleerd", color: "var(--text-muted)" }, }; const s = map[status] || { label: status, color: "var(--text-muted)" }; return ( {s.label} ); } // ============================================================ // Voertuigen-tab // ============================================================ function Voertuigen({ vehicles, onAdd, onEdit, onDelete, onNav }) { return (
{vehicles.map((v) => (
{v.primary && Primair}
{v.merk}
{v.jaar ? `Bouwjaar ${v.jaar}` : "Bouwjaar onbekend"}{v.fuel ? ` · ${v.fuel}` : ""}

))}
); } // ============================================================ // Afspraken-tab // ============================================================ function Afspraken({ upcoming, history, onNav, onCancel }) { return (

Aankomend

{upcoming.length === 0 && (

U heeft geen aankomende afspraken.

)} {upcoming.map((a) => (
{a.service} · {a.date} {a.time}
{a.plate} · Achterasweg 10-A, Vijfhuizen
))}

Historie

{history.length === 0 ? (

Nog geen afspraken in uw historie.

) : (
{history.map((h, i) => (
{h.date} {h.service} {h.total} {h.status !== "cancelled" ? ( ) : ( Geannuleerd )}
))}
)}
); } // ============================================================ // Facturen-tab // ============================================================ function Facturen({ history }) { if (history.length === 0) { return (

Nog geen facturen — uw facturen verschijnen hier zodra een afspraak is afgerond.

); } return (
{["Factuurnr.", "Datum", "Omschrijving", "Voertuig", "Bedrag", ""].map((h, i) => ( {h} ))}
{history.map((h, i) => (
{h.invoice} {h.date} {h.service} {h.total}
))}
); } // ============================================================ // Profiel-tab // ============================================================ function Profiel({ user, onUpdated, showToast }) { const [form, setForm] = React.useState({ name: user.name || "", phone: user.phone || "", address: user.address || "", postcode: user.postcode || "", city: user.city || "", }); const [saving, setSaving] = React.useState(false); const [pwForm, setPwForm] = React.useState({ current: "", next: "", confirm: "" }); const [pwSaving, setPwSaving] = React.useState(false); const submitProfile = async (e) => { e.preventDefault(); setSaving(true); try { await window.api.auth.updateMe(form); const fresh = await window.api.auth.me(); onUpdated(fresh.user); } catch (err) { showToast(err.message || "Kon profiel niet opslaan", "error"); } finally { setSaving(false); } }; const submitPassword = async (e) => { e.preventDefault(); if (pwForm.next !== pwForm.confirm) { showToast("Nieuwe wachtwoorden komen niet overeen", "error"); return; } if (pwForm.next.length < 8) { showToast("Wachtwoord moet minimaal 8 tekens zijn", "error"); return; } setPwSaving(true); try { await window.api.auth.changePassword(pwForm.current, pwForm.next); setPwForm({ current: "", next: "", confirm: "" }); showToast("Wachtwoord gewijzigd"); } catch (err) { showToast(err.message || "Kon wachtwoord niet wijzigen", "error"); } finally { setPwSaving(false); } }; return (
Persoonsgegevens
setForm(f => ({ ...f, name: v }))} required/> setForm(f => ({ ...f, phone: v }))} type="tel"/> setForm(f => ({ ...f, address: v }))}/>
setForm(f => ({ ...f, postcode: v }))}/> setForm(f => ({ ...f, city: v }))}/>
Wachtwoord wijzigen
setPwForm(f => ({ ...f, current: v }))} required/> setPwForm(f => ({ ...f, next: v }))} required hint="Minimaal 8 tekens"/> setPwForm(f => ({ ...f, confirm: v }))} required/>
); } function Field({ label, value, onChange, type = "text", required, disabled, hint }) { return ( ); } function Spec({ label, value, accent }) { return (
{label}
{value}
); } // ============================================================ // Voertuig toevoegen — kenteken lookup + RDW data preview // ============================================================ function AddVehicleModal({ onClose, onAdded }) { const [plate, setPlate] = React.useState(""); const [data, setData] = React.useState(null); // RDW result const [mileage, setMileage] = React.useState(""); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); const formatPlate = (raw) => raw.toUpperCase().replace(/[^A-Z0-9]/g, ""); const lookup = async (e) => { e.preventDefault(); setError(null); setData(null); const clean = formatPlate(plate); if (clean.length < 5) { setError("Vul een geldig kenteken in"); return; } setLoading(true); try { const r = await window.api.kenteken.lookup(clean); setData(r); } catch (err) { setError(err.message || "Kenteken niet gevonden"); } finally { setLoading(false); } }; const save = async () => { if (!data) return; setSaving(true); try { await window.api.vehicles.create({ license_plate: data.plate, make: data.make, model: data.model, year: data.year, fuel_type: data.fuel_type, apk_expiry: data.apk_expiry, mileage: mileage ? Number(mileage) : null, }); onAdded(); } catch (err) { setError(err.message || "Kon voertuig niet opslaan"); setSaving(false); } }; return ( {!data ? (

Vul uw kenteken in. Wij halen merk, model, brandstof en APK-vervaldatum automatisch op uit het RDW.

{error &&
{error}
}
) : (
{[data.make, data.model].filter(Boolean).join(" ") || "Onbekend"}
{data.year && `Bouwjaar ${data.year}`} {data.fuel_type && ` · ${data.fuel_type}`} {data.color && ` · ${data.color}`}
{data.apk_expiry && (
APK tot: {new Date(data.apk_expiry).toLocaleDateString("nl-NL")}
)}
{error &&
{error}
}
)}
); } // ============================================================ // Voertuig bewerken — km-stand bijwerken // ============================================================ function EditVehicleModal({ vehicle, onClose, onSaved }) { const [mileage, setMileage] = React.useState(vehicle.mileage || ""); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const save = async (e) => { e.preventDefault(); setSaving(true); setError(null); try { await window.api.vehicles.update(vehicle.id, { mileage: mileage === "" ? null : Number(mileage), }); onSaved(); } catch (err) { setError(err.message || "Kon niet opslaan"); setSaving(false); } }; return (
{vehicle.merk}
{error &&
{error}
}
); } // ============================================================ // Modal helper // ============================================================ function Modal({ title, onClose, children }) { React.useEffect(() => { const onEsc = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onEsc); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onEsc); document.body.style.overflow = ""; }; }, [onClose]); return (
e.stopPropagation()} className="card" style={{ background: "var(--surface)", padding: 28, maxWidth: 480, width: "100%", maxHeight: "90vh", overflowY: "auto", }}>

{title}

{children}
); } window.Portaal = Portaal;