// 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 (
);
}
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 && (
{history.slice(0, 3).map((h, i) => (
))}
)}
{/* Rechter kolom: voertuigen + tip */}
{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.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 (
);
}
function Field({ label, value, onChange, type = "text", required, disabled, hint }) {
return (
);
}
function Spec({ label, value, accent }) {
return (
);
}
// ============================================================
// 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 ? (
) : (
{[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 (
);
}
// ============================================================
// 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;