const { useState: useStateLive, useMemo: useMemoLive, useEffect: useEffectLive, useRef: useRefLive } = React; const STATUS_BUTTONS = [ { id: 'Open', label: 'Open' }, { id: 'Working', label: 'Bezig' }, { id: 'Completed', label: 'Klaar' }, ]; const TEMPLATE_PRIORITY_OPTIONS_LIVE = ['Low', 'Medium', 'High', 'Urgent']; const statusButtonStyle = (active) => ({ padding: '5px 9px', fontSize: 11, fontWeight: 600, borderRadius: 6, cursor: 'pointer', border: active ? '1px solid var(--ink-1)' : '1px solid var(--line)', background: active ? 'var(--ink-1)' : 'var(--paper)', color: active ? 'var(--paper)' : 'var(--ink-2)', }); const inputBaseStyle = { width: '100%', padding: '8px 10px', fontSize: 13, border: '1px solid var(--line)', borderRadius: 7, background: 'var(--paper)', color: 'var(--ink-1)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', }; const compactDateInputStyle = { ...inputBaseStyle, width: 150, padding: '6px 8px', fontSize: 11.5, }; const emptyAddress = () => ({ address_line1: '', address_line2: '', city: '', county: '', state: '', pincode: '', country: 'Netherlands', }); const withAddressDefaults = (address) => ({ ...emptyAddress(), ...(address || {}), }); const emptyContactDraft = () => ({ name: '', email: '', phone: '', role: '', }); const emptyUserDraft = () => ({ name: '', email: '', phone: '', mobile: '', roleProfile: '', }); const makeDraftId = () => `draft-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const emptyTemplateStepDraft = (taskType = '') => ({ localId: makeDraftId(), taskId: '', taskType, name: taskType || '', durationDays: 1, expectedHours: '', priority: 'Medium', milestone: false, description: '', }); const parseNumericInput = (value, fallback = 0) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }; const formatHoursLabel = (value) => { const amount = parseNumericInput(value, 0); if (!amount) return '0 uur'; const rounded = Math.round(amount * 10) / 10; return `${String(rounded).replace('.0', '')} uur`; }; const formatAddressPreview = (address) => { const normalized = withAddressDefaults(address); return [ normalized.address_line1, normalized.address_line2, [normalized.pincode, normalized.city].filter(Boolean).join(' ').trim(), normalized.country, ].filter(Boolean); }; const displayTeamMemberName = (user) => { const cleaned = String(user?.naam || '').replace(/^Pilot\s+/i, '').trim(); return cleaned || user?.naam || user?.id || 'Onbekend'; }; const emptyWorkloadStats = () => ({ open: 0, overdue: 0, deze_week: 0, vandaag: 0, }); const isWorkdayLive = (date) => { const weekday = date.getDay(); return weekday >= 1 && weekday <= 5; }; const shiftToWorkdayLive = (date, direction = 'next') => { if (!date) return null; const cursor = new Date(date); cursor.setHours(0, 0, 0, 0); const step = direction === 'previous' ? -1 : 1; while (!isWorkdayLive(cursor)) { cursor.setDate(cursor.getDate() + step); } return cursor; }; const addWorkdaysLive = (date, amount) => { const cursor = shiftToWorkdayLive(date, 'next'); let remaining = Math.max(parseNumericInput(amount, 0), 0); while (remaining > 0) { cursor.setDate(cursor.getDate() + 1); if (isWorkdayLive(cursor)) remaining -= 1; } return cursor; }; const buildTemplateSchedulePreview = (template, startDateValue) => { if (!template || !(template.steps || []).length) return null; const baseStart = shiftToWorkdayLive(startDateValue || window.todayDate || new Date(), 'next'); let currentStart = new Date(baseStart); let lastEnd = new Date(baseStart); const stepSchedule = template.steps.map((step) => { const durationDays = Math.max(parseNumericInput(step.durationDays, 1), 1); const start = new Date(currentStart); const end = addWorkdaysLive(start, durationDays - 1); lastEnd = new Date(end); currentStart = addWorkdaysLive(end, 1); return { ...step, start: window.iso(start), end: window.iso(end), durationDays, }; }); return { start: window.iso(baseStart), end: window.iso(lastEnd), stepCount: stepSchedule.length, durationDays: stepSchedule.reduce((sum, step) => sum + step.durationDays, 0), expectedHours: stepSchedule.reduce((sum, step) => sum + parseNumericInput(step.expectedHours, 0), 0), steps: stepSchedule, }; }; function getMyDayAgendaSnapshotLive(userId) { const today = String(window.today || window.iso(new Date())); const tasks = window.getMyTasks(userId); const prepared = tasks.map((task) => { const start = task.start || task.deadline || ''; const deadline = task.deadline || task.start || ''; const overdue = Boolean(deadline && window.daysUntil(deadline) < 0); const activeToday = Boolean(start && deadline && start <= today && deadline >= today); const anchorDate = activeToday ? today : (start || deadline || ''); return { ...task, start, deadline, overdue, anchorDate, }; }); const overdueTasks = prepared.filter((task) => task.overdue); const plannedTasks = prepared.filter((task) => !task.overdue); const timelineKeys = [ ...new Set( plannedTasks .filter((task) => task.anchorDate) .map((task) => task.anchorDate) .sort() ), ].slice(0, 5); const visiblePlannedTasks = plannedTasks.filter((task) => timelineKeys.includes(task.anchorDate)); return { allCount: tasks.length, visibleCount: overdueTasks.length + visiblePlannedTasks.length, }; } function buildSidebarSections() { const myDaySnapshot = getMyDayAgendaSnapshotLive(window.CURRENT_USER_ID); return [ { title: null, items: [ { id: 'kanban', label: 'Kanban', icon: 'kanban', count: window.PROJECTEN.length }, { id: 'mijndag', label: 'Mijn dag', icon: 'today', count: myDaySnapshot.visibleCount, hot: myDaySnapshot.visibleCount > 0 }, { id: 'perklant', label: 'Per klant', icon: 'klant' }, { id: 'lijst', label: 'Alle projecten', icon: 'lijst' }, { id: 'team', label: 'Team workload', icon: 'team' }, ], }, { title: 'Configuratie', items: [ { id: 'klanten', label: 'Klanten', icon: 'klant', count: window.KLANTEN.length }, { id: 'gebruikers', label: 'Gebruikers', icon: 'team', count: window.GEBRUIKERS.length }, { id: 'templates', label: 'Templates', icon: 'settings', count: window.PROJECT_TEMPLATES.filter((template) => !template.disabled).length }, ], }, ]; } const SidebarLive = ({ view, setView, onNewProject }) => { const currentUser = window.medewerkerById(window.CURRENT_USER_ID); const currentConfigUser = window.GEBRUIKERS.find((user) => user.id === window.CURRENT_USER_ID) || null; const [switchingUser, setSwitchingUser] = useStateLive(false); const [switchError, setSwitchError] = useStateLive(''); const [userMenuOpen, setUserMenuOpen] = useStateLive(false); const userMenuRef = useRefLive(null); const switchableUsers = useMemoLive(() => ( [...window.GEBRUIKERS].sort((left, right) => ( String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), [window.GEBRUIKERS]); useEffectLive(() => { if (!userMenuOpen) return undefined; const handleOutsideClick = (event) => { if (userMenuRef.current && !userMenuRef.current.contains(event.target)) { setUserMenuOpen(false); } }; document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('mousedown', handleOutsideClick); }, [userMenuOpen]); const handleUserSwitch = async (nextUserId) => { if (!nextUserId) return; if (nextUserId === window.CURRENT_USER_ID) { setUserMenuOpen(false); return; } setSwitchingUser(true); setSwitchError(''); try { await window.dashboardApi.switchCurrentUser(nextUserId); setUserMenuOpen(false); } catch (error) { setSwitchError(error.message || 'Wisselen van gebruiker mislukt.'); } finally { setSwitchingUser(false); } }; return ( ); }; const TopbarLive = ({ view, search, setSearch }) => { const myDaySnapshot = getMyDayAgendaSnapshotLive(window.CURRENT_USER_ID); const titles = { kanban: 'NPD Ontwikkeling', mijndag: 'Mijn dag', perklant: 'Per klant', klanten: 'Klanten', gebruikers: 'Gebruikers', templates: 'Projecttemplates', lijst: 'Alle projecten', team: 'Team workload', }; const subtitles = { kanban: `${window.PROJECTEN.length} lopende projecten · ${window.BUCKETS.length} buckets`, mijndag: `${myDaySnapshot.visibleCount} taken in beeld · ${window.todayHuman}`, perklant: `${window.KLANTEN.length} klanten met lopende of potentiële dashboardprojecten`, klanten: '', gebruikers: '', templates: `${window.PROJECT_TEMPLATES.length} templates voor automatische stapplanning`, lijst: 'Zoek, filter en demo vanuit live ERPNext-data', team: `${window.ALL_TASKS.length} openstaande stappen`, }; return (

{titles[view]}

{subtitles[view]}
setSearch(event.target.value)} placeholder="Zoek project, klant, saus…" style={{ border: 'none', outline: 'none', background: 'transparent', fontSize: 13, flex: 1, color: 'var(--ink-1)', fontFamily: 'inherit', }} /> ⌘K
); }; const NewProjectModalLive = ({ onClose, onCreated }) => { const customers = window.KLANTEN; const activeTemplates = useMemoLive(() => ( [...window.PROJECT_TEMPLATES] .filter((template) => !template.disabled) .sort((left, right) => String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl')) ), [window.PROJECT_TEMPLATES]); const [klantId, setKlantId] = useStateLive(customers[0]?.id || ''); const [naam, setNaam] = useStateLive(''); const [prio, setPrio] = useStateLive('gemiddeld'); const [deadline, setDeadline] = useStateLive(() => window.iso(window.addDays(window.todayDate || new Date(), 21))); const [sauzen, setSauzen] = useStateLive(['']); const [templateId, setTemplateId] = useStateLive(''); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const selectedTemplate = templateId ? window.templateById(templateId) : null; const templatePreview = selectedTemplate ? buildTemplateSchedulePreview(selectedTemplate, window.todayDate || new Date()) : null; const templateFinishAfterDeadline = Boolean( templatePreview && deadline && new Date(`${templatePreview.end}T00:00:00`).getTime() > new Date(`${deadline}T00:00:00`).getTime() ); const saveProject = async () => { setError(''); if (!naam.trim()) { setError('Vul een projectnaam in.'); return; } if (!klantId) { setError('Selecteer een klant.'); return; } if (!deadline) { setError('Vul een einddatum in voor het project.'); return; } const cleanedSauces = sauzen.map((item) => item.trim()).filter(Boolean); if (cleanedSauces.length === 0) { setError('Voeg minimaal één saus toe.'); return; } setBusy(true); try { const result = await window.dashboardApi.createProject({ customer: klantId, name: naam.trim(), prio, deadline, sauces: cleanedSauces, projectTemplate: templateId || null, }); if (onCreated) onCreated(result.projectId); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
NIEUWE AANVRAAG

Project aanmaken

{window.NEXT_PROJECT_ID_PREVIEW || 'Projectnummer wordt geladen'}
{customers.length > 0 ? ( ) : (
Voeg eerst een klant toe in de tab Klanten.
)}
setNaam(event.target.value)} placeholder="bijv. Appelmoes lijn 2026" style={inputBaseStyle} />
{selectedTemplate && templatePreview && (
PER SAUS
{templatePreview.stepCount} stappen
PLANNING
{templatePreview.durationDays} werkdagen
UREN
{formatHoursLabel(templatePreview.expectedHours)}
VERWACHTE EINDDATUM
{window.fmtDateLong(templatePreview.end)}
{templatePreview.steps.map((step, index) => (
{step.name}
{step.taskType || 'Geen taaktype'} · {step.durationDays} werkdag{step.durationDays === 1 ? '' : 'en'} {parseNumericInput(step.expectedHours, 0) > 0 && ` · ${formatHoursLabel(step.expectedHours)}`}
{step.milestone && ( Milestone )}
{window.fmtDate(step.start)} - {window.fmtDate(step.end)}
))}
{templateFinishAfterDeadline && (
Deze template loopt door tot {window.fmtDateLong(templatePreview.end)} en valt daarmee na de ingevulde klantdeadline.
)}
)}
setDeadline(event.target.value)} style={inputBaseStyle} />
{Object.entries(window.PRIORITY).map(([id, item]) => ( ))}
{sauzen.map((saus, index) => (
{ const next = [...sauzen]; next[index] = event.target.value; setSauzen(next); }} placeholder={`Saus ${index + 1}`} style={inputBaseStyle} /> {sauzen.length > 1 && ( )}
))}
{error && (
{error}
)}
); }; const NewCustomerModalLive = ({ onClose, onCreated }) => { const [naam, setNaam] = useStateLive(''); const [address, setAddress] = useStateLive(() => emptyAddress()); const [contacts, setContacts] = useStateLive([emptyContactDraft()]); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const updateContactRow = (index, patch) => { const next = [...contacts]; next[index] = { ...next[index], ...patch }; setContacts(next); }; const addContactRow = () => setContacts([...contacts, emptyContactDraft()]); const removeContactRow = (index) => { if (contacts.length === 1) { setContacts([emptyContactDraft()]); return; } setContacts(contacts.filter((_, itemIndex) => itemIndex !== index)); }; const saveCustomer = async () => { if (!naam.trim()) { setError('Vul een klantnaam in.'); return; } setBusy(true); setError(''); try { const result = await window.dashboardApi.createCustomer({ name: naam.trim(), address, contacts, }); if (onCreated) onCreated(result.customerId); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
NIEUWE KLANT

Klant aanmaken

setNaam(event.target.value)} placeholder="bijv. Sauce Retail BV" style={inputBaseStyle} />
setAddress({ ...address, address_line1: event.target.value })} placeholder="Straat en nummer" style={inputBaseStyle} /> setAddress({ ...address, address_line2: event.target.value })} placeholder="Extra adresregel" style={inputBaseStyle} />
setAddress({ ...address, pincode: event.target.value })} placeholder="Postcode" style={inputBaseStyle} /> setAddress({ ...address, city: event.target.value })} placeholder="Plaats" style={inputBaseStyle} />
setAddress({ ...address, country: event.target.value })} placeholder="Land" style={inputBaseStyle} />
{contacts.map((contact, index) => (
updateContactRow(index, { name: event.target.value })} placeholder="Naam" style={inputBaseStyle} />
updateContactRow(index, { email: event.target.value })} placeholder="Mail" style={inputBaseStyle} /> updateContactRow(index, { phone: event.target.value })} placeholder="Telefoon" style={inputBaseStyle} />
updateContactRow(index, { role: event.target.value })} placeholder="Functie" style={inputBaseStyle} />
))}
{error && (
{error}
)}
); }; const EditCustomerModalLive = ({ customer, projectCount, onClose, onSaved }) => { const [naam, setNaam] = useStateLive(customer.naam || ''); const [address, setAddress] = useStateLive(() => withAddressDefaults(customer.address)); const [contacts, setContacts] = useStateLive(() => ( (customer.contacts || []).map((contact) => ({ id: contact.id, name: contact.naam || '', email: contact.email || '', phone: contact.phone || '', role: contact.role || '', })) )); const [removedContactIds, setRemovedContactIds] = useStateLive([]); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const updateContactRow = (index, patch) => { setContacts((current) => current.map((contact, itemIndex) => ( itemIndex === index ? { ...contact, ...patch } : contact ))); }; const addContactRow = () => { setContacts((current) => [...current, emptyContactDraft()]); }; const removeContactRow = (index) => { setContacts((current) => { const target = current[index]; if (target?.id) { setRemovedContactIds((existing) => [...existing, target.id]); } return current.filter((_, itemIndex) => itemIndex !== index); }); }; const saveCustomer = async () => { if (!naam.trim()) { setError('Vul een klantnaam in.'); return; } const filledContacts = contacts .map((contact) => ({ id: contact.id, name: String(contact.name || '').trim(), email: String(contact.email || '').trim(), phone: String(contact.phone || '').trim(), role: String(contact.role || '').trim(), })) .filter((contact) => contact.name || contact.email || contact.phone || contact.role); if (filledContacts.some((contact) => !contact.name)) { setError('Elke contactpersoon moet een naam hebben.'); return; } setBusy(true); setError(''); try { await window.dashboardApi.updateCustomer(customer.id, { name: naam.trim(), address, }); for (const contactId of removedContactIds) { await window.dashboardApi.deleteContact(contactId); } for (const contact of filledContacts) { const payload = { name: contact.name, email: contact.email, phone: contact.phone, role: contact.role, }; if (contact.id) { await window.dashboardApi.updateContact(contact.id, payload); } else { await window.dashboardApi.addCustomerContact(customer.id, payload); } } if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; const deleteCustomer = async () => { if (projectCount > 0) { setError('Verwijder eerst de gekoppelde projecten of kies een klant zonder projecten.'); return; } if (!window.confirm(`Klant "${customer.naam}" verwijderen?`)) return; setBusy(true); setError(''); try { await window.dashboardApi.deleteCustomer(customer.id); if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
KLANT BEWERKEN

{customer.naam}

setNaam(event.target.value)} placeholder="Klantnaam" style={inputBaseStyle} />
setAddress({ ...address, address_line1: event.target.value })} placeholder="Straat en nummer" style={inputBaseStyle} /> setAddress({ ...address, address_line2: event.target.value })} placeholder="Extra adresregel" style={inputBaseStyle} />
setAddress({ ...address, pincode: event.target.value })} placeholder="Postcode" style={inputBaseStyle} /> setAddress({ ...address, city: event.target.value })} placeholder="Plaats" style={inputBaseStyle} />
setAddress({ ...address, country: event.target.value })} placeholder="Land" style={inputBaseStyle} />
{contacts.length === 0 && (
Nog geen contactpersonen toegevoegd.
)} {contacts.map((contact, index) => (
updateContactRow(index, { name: event.target.value })} placeholder="Naam" style={inputBaseStyle} />
updateContactRow(index, { email: event.target.value })} placeholder="Mail" style={inputBaseStyle} /> updateContactRow(index, { phone: event.target.value })} placeholder="Telefoon" style={inputBaseStyle} />
updateContactRow(index, { role: event.target.value })} placeholder="Functie" style={inputBaseStyle} />
))}
{error && (
{error}
)}
); }; const ContactModalLive = ({ customer, contact, onClose, onSaved, onDeleted }) => { const [draft, setDraft] = useStateLive(() => ({ name: contact?.naam || '', email: contact?.email || '', phone: contact?.phone || '', role: contact?.role || '', })); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const saveContact = async () => { if (!draft.name.trim()) { setError('Vul een naam in voor de contactpersoon.'); return; } setBusy(true); setError(''); try { if (contact) { await window.dashboardApi.updateContact(contact.id, draft); } else { await window.dashboardApi.addCustomerContact(customer.id, draft); } if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; const deleteContact = async () => { if (!contact) return; if (!window.confirm(`Contactpersoon "${contact.naam}" verwijderen?`)) return; setBusy(true); setError(''); try { await window.dashboardApi.deleteContact(contact.id); if (onDeleted) onDeleted(); else if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
{contact ? 'CONTACTPERSOON BEWERKEN' : 'CONTACTPERSOON TOEVOEGEN'}

{customer.naam}

setDraft({ ...draft, name: event.target.value })} placeholder="Naam" style={inputBaseStyle} /> setDraft({ ...draft, email: event.target.value })} placeholder="Mail" style={inputBaseStyle} /> setDraft({ ...draft, phone: event.target.value })} placeholder="Telefoon" style={inputBaseStyle} /> setDraft({ ...draft, role: event.target.value })} placeholder="Functie" style={inputBaseStyle} /> {error && (
{error}
)}
{contact ? ( ) : }
); }; const UserModalLive = ({ user, onClose, onSaved }) => { const defaultRoleProfile = user?.roleProfile || window.USER_ROLE_PROFILES.find((item) => item.id.startsWith('NPD Pilot - '))?.id || window.USER_ROLE_PROFILES[0]?.id || ''; const [draft, setDraft] = useStateLive(() => ({ name: user?.naam || '', email: user?.email || '', phone: user?.phone || '', mobile: user?.mobile || '', roleProfile: defaultRoleProfile, })); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const saveUser = async () => { if (!draft.name.trim()) { setError('Vul een naam in voor de gebruiker.'); return; } if (!draft.email.trim()) { setError('Vul een mailadres in voor de gebruiker.'); return; } if (!draft.roleProfile) { setError('Selecteer een rol voor de gebruiker.'); return; } setBusy(true); setError(''); try { const payload = { name: draft.name.trim(), email: draft.email.trim(), phone: draft.phone.trim(), mobile: draft.mobile.trim(), roleProfile: draft.roleProfile, }; if (user) { await window.dashboardApi.updateUser(user.id, payload); } else { await window.dashboardApi.createUser(payload); } if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
{user ? 'GEBRUIKER BEWERKEN' : 'NIEUWE GEBRUIKER'}

{user ? user.naam : 'Gebruiker aanmaken'}

setDraft({ ...draft, name: event.target.value })} placeholder="Naam" style={inputBaseStyle} /> setDraft({ ...draft, email: event.target.value })} placeholder="mail@voorbeeld.nl" style={inputBaseStyle} />
setDraft({ ...draft, phone: event.target.value })} placeholder="Telefoon" style={inputBaseStyle} /> setDraft({ ...draft, mobile: event.target.value })} placeholder="Mobiel" style={inputBaseStyle} />
{error && (
{error}
)}
); }; const FieldLive = ({ label, hint, children }) => (
{hint && {hint}}
{children}
); const StepControls = ({ step }) => { const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const stepUsers = useMemoLive(() => ( [...window.GEBRUIKERS].sort((left, right) => ( String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), [window.GEBRUIKERS]); const updateStatus = async (status) => { setBusy(true); setError(''); try { await window.dashboardApi.updateTask(step.taskName, { status }); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; const updateWaitingReason = async (event) => { setBusy(true); setError(''); try { await window.dashboardApi.updateTask(step.taskName, { waitingReason: event.target.value }); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; const updateDateField = async (field, value) => { setBusy(true); setError(''); try { await window.dashboardApi.updateTask(step.taskName, { [field]: value || null }); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; const updateOwner = async (event) => { setBusy(true); setError(''); try { await window.dashboardApi.updateTask(step.taskName, { owner: event.target.value || null }); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return (
{STATUS_BUTTONS.map((item) => ( ))}
{error &&
{error}
}
); }; const StepQuickEditModalLive = ({ variant, step, onClose }) => { if (!variant || !step) return null; return ( <>
STAP BEWERKEN

{step.name}

{variant.naam} {step.deadline && }
Pas hier direct de status, verantwoordelijke en planning aan zonder te hoeven slepen in de Gantt.
); }; const AddVariantStepModalLive = ({ p, variant, onClose, onSaved }) => { const stepOptions = useMemoLive(() => ( [...window.BUCKETS] .filter((bucket) => bucket.id !== '—') .sort((left, right) => ( (left.sequence || 0) - (right.sequence || 0) || String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), []); const [bucketId, setBucketId] = useStateLive(stepOptions[0]?.id || ''); const [startDate, setStartDate] = useStateLive(''); const [deadline, setDeadline] = useStateLive(variant.deadline || p.einde || ''); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const saveStep = async () => { const selectedBucket = stepOptions.find((bucket) => bucket.id === bucketId); if (!selectedBucket) { setError('Selecteer een stap.'); return; } setBusy(true); setError(''); try { await window.dashboardApi.addVariantStep(variant.taskName, { taskType: selectedBucket.naam, startDate: startDate || null, deadline: deadline || null, }); if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
STAP TOEVOEGEN

{variant.naam}

setStartDate(event.target.value)} style={inputBaseStyle} /> setDeadline(event.target.value)} style={inputBaseStyle} />
{error && (
{error}
)}
); }; const VariantDeadlineControlLive = ({ variant }) => { const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const updateDeadline = async (event) => { setBusy(true); setError(''); try { await window.dashboardApi.updateTask(variant.taskName, { deadline: event.target.value || null }); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return (
{error &&
{error}
}
); }; const VariantPlanCardLive = ({ p, variant }) => { const [addStepOpen, setAddStepOpen] = useStateLive(false); const currentBucket = window.bucketById(variant.bucketId); const hasSteps = variant.steps.length > 0; return (
{variant.naam}
Huidige bucket: {hasSteps ? currentBucket.naam : 'Nog geen stappen'} {variant.deadline && <> · deadline {window.fmtDate(variant.deadline)}}
{hasSteps ? : Nog leeg} {Math.round((variant.progress || 0) * 100)}%
{variant.blocker && (
Blokkade: {variant.blocker}
)}
{hasSteps ? variant.steps.map((step) => { const teamColors = window.TEAM_COLORS[step.team] || window.TEAM_COLORS['—']; const owner = step.owner ? window.medewerkerById(step.owner) : null; const isCurrent = step.id === variant.bucketId && !step.done; const overdue = !step.done && window.daysUntil(step.deadline) < 0; return (
{step.done && }
{step.name}
{owner && } {owner ? owner.naam : 'Niet toegewezen'} {step.waitingReason !== 'none' ? `wacht op ${step.waitingReason}` : 'actief'}
); }) : (
Nog geen stappen toegevoegd voor deze saus. Voeg de eerste stap handmatig toe zodra deze variant klaar is om verder te gaan.
)}
{addStepOpen && ( setAddStepOpen(false)} onSaved={() => setAddStepOpen(false)} /> )}
); }; const GANTT_VISIBLE_WEEKS = 4; const GANTT_WORKDAYS_PER_WEEK = 5; const GANTT_VISIBLE_DAYS = GANTT_VISIBLE_WEEKS * GANTT_WORKDAYS_PER_WEEK; const GANTT_FORWARD_WEEKS = 26; const GANTT_VARIANT_TONES = [ { soft: 'oklch(0.96 0.03 45)', border: 'oklch(0.82 0.08 45)', accent: 'oklch(0.58 0.16 42)', fill: 'oklch(0.72 0.11 44)', ink: 'oklch(0.44 0.10 40)', }, { soft: 'oklch(0.96 0.03 250)', border: 'oklch(0.82 0.07 245)', accent: 'oklch(0.60 0.14 250)', fill: 'oklch(0.74 0.10 248)', ink: 'oklch(0.42 0.09 248)', }, { soft: 'oklch(0.96 0.03 155)', border: 'oklch(0.82 0.07 155)', accent: 'oklch(0.56 0.13 155)', fill: 'oklch(0.72 0.09 155)', ink: 'oklch(0.40 0.08 155)', }, { soft: 'oklch(0.96 0.03 320)', border: 'oklch(0.82 0.07 318)', accent: 'oklch(0.57 0.13 318)', fill: 'oklch(0.72 0.09 318)', ink: 'oklch(0.41 0.08 318)', }, ]; const parsePlanningDate = (value) => { if (!value) return null; const parsed = new Date(`${String(value).slice(0, 10)}T00:00:00`); return Number.isNaN(parsed.getTime()) ? null : parsed; }; const addPlanningDays = (date, amount) => { const next = new Date(date); next.setDate(next.getDate() + amount); return next; }; const diffPlanningDays = (left, right) => Math.round((right.getTime() - left.getTime()) / 86400000); const planningDateKey = (date) => { if (!date) return ''; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const isPlanningWorkday = (date) => { const weekday = date.getDay(); return weekday >= 1 && weekday <= 5; }; const shiftToPlanningWorkday = (date, direction = 'next') => { if (!date) return null; const cursor = new Date(date); cursor.setHours(0, 0, 0, 0); const step = direction === 'previous' ? -1 : 1; while (!isPlanningWorkday(cursor)) { cursor.setDate(cursor.getDate() + step); } return cursor; }; const startOfPlanningWeek = (date) => { const cursor = new Date(date); cursor.setHours(0, 0, 0, 0); const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay(); cursor.setDate(cursor.getDate() - weekday + 1); return cursor; }; const endOfPlanningWeek = (date) => addPlanningDays(startOfPlanningWeek(date), 4); const buildPlanningDates = (startDate, endDate) => { const dates = []; let cursor = new Date(startDate); while (cursor <= endDate) { if (isPlanningWorkday(cursor)) { dates.push(new Date(cursor)); } cursor = addPlanningDays(cursor, 1); } return dates; }; const getVariantTone = (index) => GANTT_VARIANT_TONES[index % GANTT_VARIANT_TONES.length]; const formatPlanningWeekday = (date) => ( date.toLocaleDateString('nl-NL', { weekday: 'short' }).replace('.', '') ); const formatPlanningMonth = (date) => ( date.toLocaleDateString('nl-NL', { month: 'short' }).toUpperCase() ); const buildProjectPlanningRange = (project, variants) => { const dates = [ parsePlanningDate(project.start), parsePlanningDate(project.einde), parsePlanningDate(window.today), ...variants.flatMap((variant) => [ parsePlanningDate(variant.deadline), ...variant.steps.flatMap((step) => [ parsePlanningDate(step.start), parsePlanningDate(step.deadline), ]), ]), ].filter(Boolean).sort((left, right) => left - right); const fallback = shiftToPlanningWorkday(parsePlanningDate(window.today) || new Date(), 'next'); const earliest = dates[0] ? shiftToPlanningWorkday(dates[0], 'previous') : fallback; const latest = dates[dates.length - 1] ? shiftToPlanningWorkday(dates[dates.length - 1], 'next') : fallback; const today = fallback; const prefersToday = today >= earliest && today <= latest; const preferred = prefersToday ? today : earliest; let start = startOfPlanningWeek(earliest); let end = endOfPlanningWeek(latest); const preferredWindowEnd = endOfPlanningWeek(addPlanningDays(startOfPlanningWeek(preferred), ((GANTT_VISIBLE_WEEKS - 1) * 7))); const minimumWindowEnd = endOfPlanningWeek(addPlanningDays(start, ((GANTT_VISIBLE_WEEKS - 1) * 7))); const forwardWindowEnd = endOfPlanningWeek(addPlanningDays(startOfPlanningWeek(preferred), ((GANTT_FORWARD_WEEKS - 1) * 7))); if (end < preferredWindowEnd) end = preferredWindowEnd; if (end < minimumWindowEnd) end = minimumWindowEnd; if (end < forwardWindowEnd) end = forwardWindowEnd; const planningDates = buildPlanningDates(start, end); const maxWeekShift = Math.max((planningDates.length / GANTT_WORKDAYS_PER_WEEK) - GANTT_VISIBLE_WEEKS, 0); const initialWeekShift = Math.min( Math.max(Math.round(diffPlanningDays(start, startOfPlanningWeek(preferred)) / 7), 0), maxWeekShift, ); return { start, end, dates: planningDates, indexByDate: new Map(planningDates.map((date, index) => [planningDateKey(date), index])), maxWeekShift, initialWeekShift, }; }; const clampPlanningIndex = (value, maxIndex) => Math.max(0, Math.min(value, maxIndex)); const planningIndexFromClientX = (clientX, timelineLeft, dayWidth, maxIndex) => ( clampPlanningIndex(Math.floor((clientX - timelineLeft) / dayWidth), maxIndex) ); const findPlanningDateIndex = (planningRange, target, direction = 'next') => { if (!planningRange.dates.length) return 0; const normalized = shiftToPlanningWorkday(target, direction); if (!normalized) return 0; const key = planningDateKey(normalized); if (planningRange.indexByDate.has(key)) { return planningRange.indexByDate.get(key); } if (normalized < planningRange.dates[0]) return 0; return planningRange.dates.length - 1; }; const ProjectDetailStatLive = ({ label, value, tone = 'neutral' }) => { const color = tone === 'success' ? 'oklch(0.47 0.11 155)' : tone === 'alert' ? 'oklch(0.48 0.16 25)' : 'var(--ink-1)'; return (
{value}
{label}
); }; const ProjectAddStepModalLive = ({ p, initialVariantId, onClose, onSaved }) => { const stepOptions = useMemoLive(() => ( [...window.BUCKETS] .filter((bucket) => bucket.id !== '—') .sort((left, right) => ( (left.sequence || 0) - (right.sequence || 0) || String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), []); const [variantId, setVariantId] = useStateLive(initialVariantId || p.variants[0]?.id || ''); const [bucketId, setBucketId] = useStateLive(stepOptions[0]?.id || ''); const [startDate, setStartDate] = useStateLive(''); const [deadline, setDeadline] = useStateLive(''); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const selectedVariant = p.variants.find((variant) => variant.id === variantId) || p.variants[0] || null; useEffectLive(() => { setDeadline(selectedVariant?.deadline || p.einde || ''); }, [selectedVariant?.id, selectedVariant?.deadline, p.einde]); const saveStep = async () => { const selectedBucket = stepOptions.find((bucket) => bucket.id === bucketId); if (!selectedVariant) { setError('Selecteer eerst een saus.'); return; } if (!selectedBucket) { setError('Selecteer een stap.'); return; } setBusy(true); setError(''); try { await window.dashboardApi.addVariantStep(selectedVariant.taskName, { taskType: selectedBucket.naam, startDate: startDate || null, deadline: deadline || null, }); if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
NIEUWE STAP

{p.naam}

setStartDate(event.target.value)} style={inputBaseStyle} /> setDeadline(event.target.value)} style={inputBaseStyle} />
{error && (
{error}
)}
); }; const DetailPlanTab = ({ p }) => { const [weekShift, setWeekShift] = useStateLive(0); const [activeVariantId, setActiveVariantId] = useStateLive('all'); const [detailsOpen, setDetailsOpen] = useStateLive(true); const [dragState, setDragState] = useStateLive(null); const [selectedStepRef, setSelectedStepRef] = useStateLive(null); const [interactionBusy, setInteractionBusy] = useStateLive(false); const [interactionError, setInteractionError] = useStateLive(''); const ganttViewportRef = useRefLive(null); const dragStateRef = useRefLive(null); const suppressStepModalUntilRef = useRefLive(0); const [ganttViewportWidth, setGanttViewportWidth] = useStateLive(0); const syncDragState = (nextOrUpdater) => { const current = dragStateRef.current; const next = typeof nextOrUpdater === 'function' ? nextOrUpdater(current) : nextOrUpdater; dragStateRef.current = next; setDragState(next); }; useEffectLive(() => { setWeekShift(0); setActiveVariantId('all'); setDetailsOpen(true); setSelectedStepRef(null); syncDragState(null); setInteractionBusy(false); setInteractionError(''); }, [p.id]); useEffectLive(() => { const element = ganttViewportRef.current; if (!element) return undefined; const updateWidth = () => { setGanttViewportWidth(element.getBoundingClientRect().width); }; updateWidth(); if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(() => updateWidth()); observer.observe(element); return () => observer.disconnect(); } window.addEventListener('resize', updateWidth); return () => window.removeEventListener('resize', updateWidth); }, []); const variantToneMap = useMemoLive(() => ( Object.fromEntries(p.variants.map((variant, index) => [variant.id, getVariantTone(index)])) ), [p.variants]); const visibleVariants = useMemoLive(() => ( activeVariantId === 'all' ? p.variants : p.variants.filter((variant) => variant.id === activeVariantId) ), [activeVariantId, p.variants]); const orderedPlanningBuckets = useMemoLive(() => ( [...window.BUCKETS] .filter((bucket) => bucket.id !== '—') .sort((left, right) => ( (left.sequence || 0) - (right.sequence || 0) || String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), [window.BUCKETS]); const planningContext = useMemoLive(() => buildProjectPlanningRange(p, p.variants), [p]); useEffectLive(() => { setWeekShift(planningContext.initialWeekShift); }, [p.id, planningContext.initialWeekShift]); useEffectLive(() => { setWeekShift((current) => Math.max(0, Math.min(current, planningContext.maxWeekShift))); }, [planningContext.maxWeekShift]); const visibleStartIndex = Math.min(weekShift * GANTT_WORKDAYS_PER_WEEK, Math.max(planningContext.dates.length - GANTT_VISIBLE_DAYS, 0)); const planningRange = useMemoLive(() => { const dates = planningContext.dates.slice(visibleStartIndex, visibleStartIndex + GANTT_VISIBLE_DAYS); return { startIndex: visibleStartIndex, endIndex: visibleStartIndex + Math.max(dates.length - 1, 0), dates, }; }, [planningContext, visibleStartIndex]); const labelWidth = 248; const availableTimelineWidth = Math.max((ganttViewportWidth || 0) - labelWidth - 2, 560); const dayWidth = availableTimelineWidth / Math.max(planningRange.dates.length || GANTT_VISIBLE_DAYS, 1); const timelineWidth = availableTimelineWidth; const compactMonthHeader = dayWidth < 30; const maxPlanningIndex = Math.max(planningRange.dates.length - 1, 0); const canShiftBack = weekShift > 0; const canShiftForward = weekShift < planningContext.maxWeekShift; const todayPlanningKey = planningDateKey(shiftToPlanningWorkday(parsePlanningDate(window.today), 'next')); const planningWindowLabel = planningRange.dates.length ? `${planningRange.dates[0].toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })} - ${planningRange.dates[planningRange.dates.length - 1].toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })}` : ''; const selectedVariant = selectedStepRef ? p.variants.find((variant) => variant.id === selectedStepRef.variantId) || null : null; const selectedStep = selectedStepRef && selectedVariant ? selectedVariant.steps.find((step) => step.taskName === selectedStepRef.taskName) || null : null; const renderTimelineBackground = (height) => ( <> {planningRange.dates.map((date, index) => (
))} ); const beginInteraction = (event, config) => { if (interactionBusy || event.button !== 0) return; event.preventDefault(); event.stopPropagation(); setInteractionError(''); syncDragState({ ...config, initialPointerX: event.clientX, previewStartIndex: config.baseStartIndex ?? config.anchorIndex ?? 0, previewEndIndex: config.baseEndIndex ?? config.anchorIndex ?? 0, }); }; useEffectLive(() => { if (!dragState) return undefined; const previousUserSelect = document.body.style.userSelect; const previousCursor = document.body.style.cursor; document.body.style.userSelect = 'none'; document.body.style.cursor = dragState.mode === 'create' ? 'crosshair' : dragState.mode === 'move' ? 'grabbing' : 'ew-resize'; const handleMouseMove = (event) => { const current = dragStateRef.current; if (!current) return; if (current.mode === 'create') { const hoveredIndex = planningIndexFromClientX( event.clientX, current.timelineLeft, current.dayWidth, current.maxIndex, ); syncDragState((state) => { if (!state) return state; return { ...state, previewStartIndex: Math.min(state.anchorIndex, hoveredIndex), previewEndIndex: Math.max(state.anchorIndex, hoveredIndex), }; }); return; } const deltaDays = Math.round((event.clientX - current.initialPointerX) / current.dayWidth); if (current.mode === 'move') { const span = current.baseEndIndex - current.baseStartIndex; const nextStartIndex = clampPlanningIndex(current.baseStartIndex + deltaDays, current.maxIndex - span); syncDragState((state) => { if (!state) return state; return { ...state, previewStartIndex: nextStartIndex, previewEndIndex: nextStartIndex + span, }; }); return; } if (current.mode === 'resize-start') { const nextStartIndex = clampPlanningIndex(current.baseStartIndex + deltaDays, current.baseEndIndex); syncDragState((state) => { if (!state) return state; return { ...state, previewStartIndex: Math.min(nextStartIndex, state.previewEndIndex), previewEndIndex: state.previewEndIndex, }; }); return; } if (current.mode === 'resize-end') { const nextEndIndex = clampPlanningIndex(current.baseEndIndex + deltaDays, current.maxIndex); syncDragState((state) => { if (!state) return state; return { ...state, previewStartIndex: state.previewStartIndex, previewEndIndex: Math.max(state.previewStartIndex, nextEndIndex), }; }); } }; const handleMouseUp = () => { const current = dragStateRef.current; syncDragState(null); document.body.style.userSelect = previousUserSelect; document.body.style.cursor = previousCursor; if (!current) return; const startDateValue = planningRange.dates[current.previewStartIndex]; const deadlineValue = planningRange.dates[current.previewEndIndex]; const unchanged = current.mode !== 'create' && current.previewStartIndex === current.baseStartIndex && current.previewEndIndex === current.baseEndIndex; if (unchanged || !startDateValue || !deadlineValue) return; suppressStepModalUntilRef.current = Date.now() + 250; const startDate = window.iso(startDateValue); const deadline = window.iso(deadlineValue); setInteractionBusy(true); setInteractionError(''); (async () => { try { if (current.mode === 'create') { await window.dashboardApi.addVariantStep(current.variantTaskName, { taskType: current.bucketName, startDate, deadline, }); } else { await window.dashboardApi.updateTask(current.taskName, { startDate, deadline, }); } } catch (error) { setInteractionError(error.message || 'Plannen in de Gantt is mislukt.'); } finally { setInteractionBusy(false); } })(); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { document.body.style.userSelect = previousUserSelect; document.body.style.cursor = previousCursor; window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [Boolean(dragState), planningRange.dates]); return (
{p.variants.map((variant) => { const tone = variantToneMap[variant.id]; const selected = activeVariantId === variant.id; return ( ); })}
4 weken
{planningWindowLabel}
{interactionError && (
{interactionError}
)}
Stap / saus
{renderTimelineBackground(58)} {planningRange.dates.map((date, index) => { return (
{formatPlanningMonth(date)}
{date.getDate()}
{formatPlanningWeekday(date)}
); })}
{visibleVariants.map((variant) => { const tone = variantToneMap[variant.id]; return (
{variant.naam}
{renderTimelineBackground(32)}
{orderedPlanningBuckets.map((bucketMeta) => { const step = variant.steps.find((item) => item.id === bucketMeta.id) || null; const rowInteraction = dragState && dragState.variantId === variant.id && dragState.bucketId === bucketMeta.id ? dragState : null; const rawStartDate = step ? parsePlanningDate(step.start || step.deadline) : null; const rawEndDate = step ? parsePlanningDate(step.deadline || step.start) : null; const fullStepStartIndex = step ? findPlanningDateIndex(planningContext, rawStartDate || rawEndDate, 'next') : 0; const fullStepEndIndexBase = step ? findPlanningDateIndex(planningContext, rawEndDate || rawStartDate, 'previous') : 0; const fullStepEndIndex = Math.max(fullStepStartIndex, fullStepEndIndexBase); const stepVisible = Boolean( step && fullStepEndIndex >= planningRange.startIndex && fullStepStartIndex <= planningRange.endIndex, ); const stepFullyVisible = Boolean( stepVisible && fullStepStartIndex >= planningRange.startIndex && fullStepEndIndex <= planningRange.endIndex, ); const stepStartIndex = stepVisible ? Math.max(fullStepStartIndex - planningRange.startIndex, 0) : 0; const stepEndIndex = stepVisible ? Math.min(fullStepEndIndex - planningRange.startIndex, planningRange.dates.length - 1) : 0; const overdue = Boolean(step && !step.done && step.deadline && window.daysUntil(step.deadline) < 0); const statusDot = !step ? 'var(--line)' : step.done ? 'oklch(0.58 0.12 150)' : overdue ? 'oklch(0.58 0.14 25)' : tone.accent; const barBackground = step?.done ? 'oklch(0.58 0.12 150)' : overdue ? 'oklch(0.66 0.15 28)' : tone.accent; const visibleStartIndex = rowInteraction ? rowInteraction.previewStartIndex : stepStartIndex; const visibleEndIndex = rowInteraction ? rowInteraction.previewEndIndex : stepEndIndex; const visibleWidth = ((step && stepVisible) || rowInteraction) ? Math.max(((visibleEndIndex - visibleStartIndex) + 1) * dayWidth, 18) : 0; const canCreate = !step && !interactionBusy; const showResizeHandles = stepFullyVisible && !interactionBusy && visibleWidth >= 28; const handleWidth = visibleWidth >= 42 ? 8 : 6; const moveInset = showResizeHandles ? handleWidth : 0; return (
{bucketMeta.naam}
{renderTimelineBackground(44)}
{ const rect = event.currentTarget.getBoundingClientRect(); const clickedIndex = planningIndexFromClientX(event.clientX, rect.left, dayWidth, maxPlanningIndex); beginInteraction(event, { mode: 'create', variantId: variant.id, variantTaskName: variant.taskName, bucketId: bucketMeta.id, bucketName: bucketMeta.naam, anchorIndex: clickedIndex, timelineLeft: rect.left, dayWidth, maxIndex: maxPlanningIndex, }); } : undefined} style={{ position: 'absolute', inset: 0, zIndex: 1 }} /> {((step && stepVisible) || rowInteraction) && (
{ if (Date.now() < suppressStepModalUntilRef.current) return; setSelectedStepRef({ variantId: variant.id, taskName: step.taskName }); } : undefined} > {stepFullyVisible && !interactionBusy && (
beginInteraction(event, { mode: 'move', variantId: variant.id, bucketId: bucketMeta.id, taskName: step.taskName, baseStartIndex: stepStartIndex, baseEndIndex: stepEndIndex, timelineLeft: event.currentTarget.parentElement.parentElement.getBoundingClientRect().left, dayWidth, maxIndex: maxPlanningIndex, })} style={{ position: 'absolute', left: moveInset, right: moveInset, top: 0, bottom: 0, minWidth: 10, cursor: 'grab', }} /> )} {showResizeHandles && ( <>
beginInteraction(event, { mode: 'resize-start', variantId: variant.id, bucketId: bucketMeta.id, taskName: step.taskName, baseStartIndex: stepStartIndex, baseEndIndex: stepEndIndex, timelineLeft: event.currentTarget.parentElement.parentElement.getBoundingClientRect().left, dayWidth, maxIndex: maxPlanningIndex, })} style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: handleWidth, cursor: 'ew-resize', background: 'transparent', borderTopLeftRadius: 6, borderBottomLeftRadius: 6, }} onClick={(event) => event.stopPropagation()} />
beginInteraction(event, { mode: 'resize-end', variantId: variant.id, bucketId: bucketMeta.id, taskName: step.taskName, baseStartIndex: stepStartIndex, baseEndIndex: stepEndIndex, timelineLeft: event.currentTarget.parentElement.parentElement.getBoundingClientRect().left, dayWidth, maxIndex: maxPlanningIndex, })} style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: handleWidth, cursor: 'ew-resize', background: 'transparent', borderTopRightRadius: 6, borderBottomRightRadius: 6, }} onClick={(event) => event.stopPropagation()} /> )}
)}
); })}
); })}
Stapdetails en bewerken
Pas onderliggende stappen, verantwoordelijken en deadlines aan zonder de planning uit het oog te verliezen.
{detailsOpen && (
{visibleVariants.map((variant) => ( ))}
)} {selectedVariant && selectedStep && ( setSelectedStepRef(null)} /> )}
); }; const DetailSauzenTab = ({ p }) => (
Dit project bestaat uit {p.sauzen.length} saus{p.sauzen.length > 1 ? 'en' : ''}. Elke saus heeft een eigen Group Task in ERPNext; stappen voeg je daarna per saus toe.
{p.variants.map((variant) => { const bucket = window.bucketById(variant.bucketId); return (
{variant.naam} {bucket.naam}
{Math.round((variant.progress || 0) * 100)}%
{variant.blocker && (
Blokkade: {variant.blocker}
)}
); })}
); const DetailCommentsTab = ({ p }) => { const [content, setContent] = useStateLive(''); const [target, setTarget] = useStateLive(`Project:${p.id}`); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const options = [ { value: `Project:${p.id}`, label: 'Project' }, ...p.variants.map((variant) => ({ value: `Task:${variant.taskName}`, label: variant.naam })), ]; const submit = async () => { if (!content.trim()) return; setBusy(true); setError(''); try { const [referenceDoctype, referenceName] = target.split(':'); await window.dashboardApi.addComment(referenceDoctype, referenceName, content.trim()); setContent(''); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return (
{p.comments.length === 0 && (
Nog geen opmerkingen geplaatst.
)} {p.comments.map((comment) => { const person = window.medewerkerById(comment.who) || { naam: comment.authorName || comment.who, initials: '??', kleur: 'oklch(0.62 0.14 30)' }; return (
{person.naam} {window.fmtDateTime(comment.time)} {comment.referenceLabel}
{comment.text}
); })}
setContent(event.target.value)} placeholder="Opmerking toevoegen…" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 12.5, color: 'var(--ink-1)', fontFamily: 'inherit', }} />
{error &&
{error}
}
); }; const DetailFilesTab = ({ p }) => (
{p.files.length === 0 && (
Nog geen bijlagen op project- of taakniveau.
)} {p.files.map((file) => { const person = window.medewerkerById(file.wie) || { naam: file.wie || 'Onbekend', initials: '??', kleur: 'oklch(0.62 0.14 30)' }; return (
{String(file.type || 'file').toUpperCase().slice(0, 4)}
{file.naam}
{file.grootte} · {window.fmtDateTime(file.wanneer)} · {person.naam} {file.referenceLabel}
); })}
); const OverviewFieldLive = ({ label, children }) => (
{label}
{children}
); const DetailOverviewTab = ({ p }) => { const klant = window.klantById(p.klant); const bucket = window.bucketById(p.huidigeBucket); const template = p.projectTemplate ? window.templateById(p.projectTemplate) : null; const allSteps = p.variants.flatMap((variant) => variant.steps); const openSteps = allSteps.filter((step) => !step.done).length; const doneSteps = allSteps.filter((step) => step.done).length; return (
{klant.naam} {p.id}
{bucket.naam}
{template?.naam || p.projectTemplate || 'Geen template gekoppeld'} {window.fmtDateLong(p.start)} {window.fmtDateLong(p.einde)}
SAUZEN
{p.variants.map((variant) => { const variantBucket = window.bucketById(variant.bucketId); return (
{variant.naam}
{variant.steps.length} stap{variant.steps.length === 1 ? '' : 'pen'}
{window.fmtDate(variant.deadline)}
); })}
STATUS
{p.sauzen.length} {openSteps} {doneSteps}
); }; const EditProjectModalLive = ({ p, onClose, onSaved }) => { const customers = window.KLANTEN; const activeTemplates = useMemoLive(() => ( [...window.PROJECT_TEMPLATES] .filter((template) => !template.disabled) .sort((left, right) => String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl')) ), [window.PROJECT_TEMPLATES]); const allStepsCount = p.variants.reduce((sum, variant) => sum + variant.steps.length, 0); const [klantId, setKlantId] = useStateLive(p.klant || customers[0]?.id || ''); const [naam, setNaam] = useStateLive(p.naam || ''); const [prio, setPrio] = useStateLive(p.prio || 'gemiddeld'); const [startDate, setStartDate] = useStateLive(p.start || ''); const [deadline, setDeadline] = useStateLive(p.einde || ''); const [projectTemplate, setProjectTemplate] = useStateLive(p.projectTemplate || ''); const [error, setError] = useStateLive(''); const [busy, setBusy] = useStateLive(false); const selectedTemplate = projectTemplate ? window.templateById(projectTemplate) : null; const templatePreview = selectedTemplate ? buildTemplateSchedulePreview(selectedTemplate, startDate ? new Date(`${startDate}T00:00:00`) : window.todayDate || new Date()) : null; const saveProject = async () => { if (!naam.trim()) { setError('Vul een projectnaam in.'); return; } if (!klantId) { setError('Selecteer een klant.'); return; } setError(''); setBusy(true); try { await window.dashboardApi.updateProject(p.id, { projectName: naam.trim(), customerId: klantId, prio, startDate: startDate || null, deadline: deadline || null, projectTemplate: projectTemplate || null, }); if (onSaved) onSaved(); } catch (exc) { setError(exc.message); } finally { setBusy(false); } }; return ( <>
PROJECT BEWERKEN

{p.id}

setNaam(event.target.value)} style={inputBaseStyle} />
{Object.entries(window.PRIORITY).map(([id, item]) => ( ))}
setStartDate(event.target.value)} style={inputBaseStyle} /> setDeadline(event.target.value)} style={inputBaseStyle} />
0 ? 'Alleen leesbaar zodra er al stappen bestaan' : 'Optioneel, wordt op alle sauzen toegepast'} > {allStepsCount > 0 ? (
{selectedTemplate?.naam || p.projectTemplate || 'Geen template gekoppeld'}
) : (
{selectedTemplate && templatePreview && (
{templatePreview.stepCount} stappen per saus · {templatePreview.durationDays} werkdagen · verwachte afronding {window.fmtDateLong(templatePreview.end)}
)}
)}
{error && (
{error}
)}
); }; const ProjectDetailLive = ({ p, onClose }) => { const [tab, setTab] = useStateLive('plan'); const [editOpen, setEditOpen] = useStateLive(false); const [projectAddStepOpen, setProjectAddStepOpen] = useStateLive(false); useEffectLive(() => { setTab('plan'); setEditOpen(false); setProjectAddStepOpen(false); }, [p?.id]); if (!p) return null; const klant = window.klantById(p.klant); const bucket = window.bucketById(p.huidigeBucket); const template = p.projectTemplate ? window.templateById(p.projectTemplate) : null; const allSteps = p.variants.flatMap((variant) => variant.steps); const openSteps = allSteps.filter((step) => !step.done).length; const doneSteps = allSteps.filter((step) => step.done).length; const overdueSteps = allSteps.filter((step) => !step.done && step.deadline && window.daysUntil(step.deadline) < 0).length; const progressPct = Math.round((p.progress || 0) * 100); const primaryVariantId = p.variants[0]?.id || ''; return (
Projectoverzicht
{p.id}

{p.naam}

{klant.naam} {bucket.naam} {p.sauzen.length} sauzen {(template || p.projectTemplate) && ( Template: {template?.naam || p.projectTemplate} )} Deadline: {p.einde ? window.fmtDateLong(p.einde) : 'Nog niet gezet'}
Opgevolgd door
{bucket.naam}
{progressPct}% afgerond
{[ { id: 'overview', label: 'Overzicht' }, { id: 'plan', label: 'Planning', count: allSteps.length }, { id: 'sauzen', label: 'Sauzen', count: p.sauzen.length }, { id: 'chat', label: 'Opmerkingen', count: p.opmerkingen }, { id: 'files', label: 'Bijlagen', count: p.bijlagen }, ].map((item) => ( ))}
{tab === 'overview' && } {tab === 'plan' && } {tab === 'sauzen' && } {tab === 'chat' && } {tab === 'files' && }
{editOpen && ( setEditOpen(false)} onSaved={() => setEditOpen(false)} /> )} {projectAddStepOpen && ( setProjectAddStepOpen(false)} onSaved={() => setProjectAddStepOpen(false)} /> )}
); }; const MijnDagLive = ({ onPick }) => { const tasks = window.getMyTasks(window.CURRENT_USER_ID); const todayDate = parsePlanningDate(window.today) || new Date(); const today = planningDateKey(todayDate); const currentUser = window.medewerkerById(window.CURRENT_USER_ID); const datedTasks = tasks.map((task) => { const start = task.start || task.deadline || ''; const deadline = task.deadline || task.start || ''; const activeToday = Boolean(start && deadline && start <= today && deadline >= today); const anchorDate = activeToday ? today : (start || deadline || ''); return { ...task, start, deadline, activeToday, anchorDate, }; }); const overdue = datedTasks.filter((task) => task.deadline && window.daysUntil(task.deadline) < 0).sort(sortMyDayTasksLive); const overdueIds = new Set(overdue.map((task) => task.id)); const plannedTasks = datedTasks.filter((task) => !overdueIds.has(task.id)); const todayTasks = plannedTasks.filter((task) => task.anchorDate === today).sort(sortMyDayTasksLive); const timelineKeys = [ ...new Set( plannedTasks .filter((task) => task.anchorDate) .map((task) => task.anchorDate) .sort() ), ].slice(0, 5); const agendaDays = timelineKeys.map((dayKey) => { const dayTasks = plannedTasks.filter((task) => task.anchorDate === dayKey).sort(sortMyDayTasksLive); return { key: dayKey, date: parsePlanningDate(dayKey) || todayDate, tasks: dayTasks, isToday: dayKey === today, }; }); const handoffReadyTasks = datedTasks.filter((task) => task.previousStep && task.readyForHandoff).sort(sortMyDayTasksLive); return (
{currentUser && }
MIJN DAG
{`${(currentUser?.naam || 'team').split(' ')[0]}, hier staat het belangrijkste.`}
{overdue.length > 0 && }
{(handoffReadyTasks.length > 0 || overdue.length > 0) && (
{handoffReadyTasks.length > 0 && ( )} {overdue.length > 0 && ( )}
)}
Eerstvolgende planning
Je eerstvolgende taken, ook als ze pas volgende week starten.
{agendaDays.length > 0 ? (
{agendaDays.map((day, index) => ( ))}
) : (
Er staan nog geen taken met een datum op jouw planning.
)}
); }; const StatTileLive = ({ label, value, tone }) => { const color = tone === 'hot' ? 'oklch(0.55 0.18 30)' : tone === 'alert' ? 'oklch(0.48 0.18 25)' : tone === 'success' ? 'oklch(0.40 0.12 150)' : tone === 'warning' ? 'oklch(0.44 0.12 60)' : 'var(--ink-1)'; const valueText = String(value); const valueFontSize = valueText.length > 6 ? 16 : valueText.length > 4 ? 18 : 22; return (
{label.toUpperCase()}
{value}
); }; const MY_DAY_PRIORITY_WEIGHT_LIVE = { dringend: 4, belangrijk: 3, gemiddeld: 2, laag: 1, }; const MY_DAY_WAITING_REASON_LABELS = { customer: 'klant', internal: 'intern', supplier: 'leverancier', review: 'review', production: 'productie', }; const formatMyDayWaitingReasonLive = (reason) => { if (!reason || reason === 'none') return ''; return MY_DAY_WAITING_REASON_LABELS[reason] || String(reason).toLowerCase(); }; const formatMyDayStepOwnerLive = (ownerId) => { if (!ownerId) return ''; const owner = window.medewerkerById(ownerId); return owner ? displayTeamMemberName(owner) : ownerId; }; const formatMyDayPreviousStepStateLive = (step) => { if (!step) return ''; if (step.done) { return step.completedOn ? `Klaar op ${window.fmtDateTime(step.completedOn)}` : 'Klaar'; } if (step.waitingReason && step.waitingReason !== 'none') { return `Wacht op ${formatMyDayWaitingReasonLive(step.waitingReason)}`; } if (step.status === 'Working') return 'Nog bezig'; return 'Nog niet klaar'; }; const getMyDayHandoffMetaLive = (task) => { const previousStep = task.previousStep || null; if (!previousStep) { return { badge: 'Startstap', bg: 'oklch(0.97 0.01 80)', border: '1px solid var(--line)', pillBg: 'var(--paper)', pillFg: 'var(--ink-2)', pillBorder: '1px solid var(--line)', }; } if (task.readyForHandoff) { return { badge: 'Klaar voor jou', bg: 'oklch(0.97 0.03 150)', border: '1px solid oklch(0.86 0.05 150)', pillBg: 'oklch(0.93 0.05 150)', pillFg: 'oklch(0.36 0.10 150)', pillBorder: '1px solid oklch(0.84 0.05 150)', }; } return { badge: 'Wacht op voorganger', bg: 'oklch(0.98 0.02 60)', border: '1px solid oklch(0.87 0.04 60)', pillBg: 'oklch(0.95 0.04 60)', pillFg: 'oklch(0.40 0.09 60)', pillBorder: '1px solid oklch(0.84 0.05 60)', }; }; const sortMyDayTasksLive = (left, right) => { const leftPriority = MY_DAY_PRIORITY_WEIGHT_LIVE[left.prio] || 0; const rightPriority = MY_DAY_PRIORITY_WEIGHT_LIVE[right.prio] || 0; const leftDeadline = left.deadline || '9999-12-31'; const rightDeadline = right.deadline || '9999-12-31'; const leftAnchor = left.anchorDate || leftDeadline; const rightAnchor = right.anchorDate || rightDeadline; const leftOverdue = left.deadline && window.daysUntil(left.deadline) < 0 ? 1 : 0; const rightOverdue = right.deadline && window.daysUntil(right.deadline) < 0 ? 1 : 0; if (leftOverdue !== rightOverdue) return rightOverdue - leftOverdue; if (leftAnchor !== rightAnchor) return leftAnchor.localeCompare(rightAnchor); if (leftDeadline !== rightDeadline) return leftDeadline.localeCompare(rightDeadline); const leftBlocked = left.previousStep && !left.readyForHandoff ? 1 : 0; const rightBlocked = right.previousStep && !right.readyForHandoff ? 1 : 0; if (leftBlocked !== rightBlocked) return leftBlocked - rightBlocked; if (leftPriority !== rightPriority) return rightPriority - leftPriority; return String(left.projectNaam || '').localeCompare(String(right.projectNaam || ''), 'nl'); }; const AgendaDaySectionLive = ({ day, onPick, first = false }) => (
{formatPlanningWeekday(day.date)}
{window.fmtDate(day.key)}
{day.isToday && ( Vandaag )}
{day.tasks.length} taak{day.tasks.length === 1 ? '' : 'en'}
{day.tasks.length > 0 ? (
{day.tasks.map((task) => )}
) : (
Geen taken gepland op deze dag.
)}
); const AgendaSupportPanelLive = ({ title, subtitle, tasks, onPick, tone, emptyLabel }) => { const alertTone = tone === 'alert'; const successTone = tone === 'success'; const warningTone = tone === 'warning'; const background = alertTone ? 'oklch(0.98 0.02 25)' : successTone ? 'oklch(0.98 0.02 150)' : warningTone ? 'oklch(0.98 0.015 60)' : 'var(--paper)'; const border = alertTone ? 'oklch(0.86 0.06 25)' : successTone ? 'oklch(0.86 0.05 150)' : warningTone ? 'oklch(0.86 0.04 60)' : 'var(--line)'; const titleColor = alertTone ? 'oklch(0.42 0.14 25)' : successTone ? 'oklch(0.36 0.10 150)' : warningTone ? 'oklch(0.40 0.09 60)' : 'var(--ink-1)'; const pillBg = alertTone ? 'oklch(0.95 0.05 25)' : successTone ? 'oklch(0.94 0.05 150)' : warningTone ? 'oklch(0.95 0.04 60)' : 'var(--surface-1)'; const pillFg = alertTone ? 'oklch(0.42 0.14 25)' : successTone ? 'oklch(0.36 0.10 150)' : warningTone ? 'oklch(0.40 0.09 60)' : 'var(--ink-2)'; return (
{title}
{subtitle &&
{subtitle}
}
{tasks.length} taak{tasks.length === 1 ? '' : 'en'}
{tasks.length > 0 ? (
{tasks.slice(0, 4).map((task) => )} {tasks.length > 4 && (
+ {tasks.length - 4} extra taak{tasks.length - 4 === 1 ? '' : 'en'}
)}
) : (
{emptyLabel || 'Geen taken.'}
)}
); }; const AgendaTaskRowLive = ({ t, onPick, compact = false }) => { const project = window.projectById(t.projectId); const bucket = window.bucketById(t.bucketId); const customer = window.klantById(t.klant); const overdue = Boolean(t.deadline && window.daysUntil(t.deadline) < 0); const clickable = Boolean(project && onPick); const handoffMeta = getMyDayHandoffMetaLive(t); return (
clickable && onPick(project)} style={{ display: 'flex', flexDirection: 'column', padding: compact ? '11px 12px' : '12px 14px', background: 'var(--paper)', border: `1px solid ${overdue ? 'oklch(0.86 0.06 25)' : 'var(--line)'}`, borderLeft: `4px solid ${overdue ? 'oklch(0.65 0.18 25)' : customer.kleur}`, borderRadius: 12, cursor: clickable ? 'pointer' : 'default', boxShadow: compact ? 'none' : '0 4px 14px rgba(15, 23, 42, 0.04)', }}>
event.stopPropagation()} onChange={() => window.dashboardApi.updateTask(t.id, { status: 'Completed' })} />
{t.variantNaam && {t.variantNaam}}
{bucket.naam}
{t.projectNaam} · {customer.naam}
{t.deadline && } {handoffMeta && ( {handoffMeta.badge} )}
); }; function useCustomerRowsLive() { const customerRows = useMemoLive(() => { const map = new Map(window.KLANTEN.map((customer) => [customer.id, { customer, projects: [] }])); window.PROJECTEN.forEach((project) => { if (!map.has(project.klant)) { map.set(project.klant, { customer: window.klantById(project.klant), projects: [] }); } map.get(project.klant).projects.push(project); }); return Array.from(map.values()) .map(({ customer, projects }) => ({ customer, projects: projects.sort((left, right) => left.naam.localeCompare(right.naam)), projectCount: projects.length, sauceCount: projects.reduce((sum, project) => sum + project.sauzen.length, 0), urgentCount: projects.filter((project) => project.prio === 'dringend').length, })) .sort((left, right) => left.customer.naam.localeCompare(right.customer.naam)); }, [window.KLANTEN, window.PROJECTEN]); return customerRows; } const PerKlantOverviewLive = ({ onPick }) => { const customerRows = useCustomerRowsLive(); const [expanded, setExpanded] = useStateLive(() => new Set(customerRows.filter((row) => row.projectCount > 0).slice(0, 2).map((row) => row.customer.id))); useEffectLive(() => { setExpanded((current) => { const validIds = new Set(customerRows.map((row) => row.customer.id)); const next = new Set([...current].filter((id) => validIds.has(id))); customerRows .filter((row) => row.projectCount > 0) .slice(0, 2) .forEach((row) => next.add(row.customer.id)); return next; }); }, [customerRows]); return (
Dit overzicht bundelt lopende projecten per klant, terwijl de brondata zelf standaard in ERPNext blijft staan.
{customerRows.map((row) => { const customer = row.customer; const projects = row.projects; const customerId = customer.id; const isOpen = expanded.has(customerId); const toggle = () => { const next = new Set(expanded); next.has(customerId) ? next.delete(customerId) : next.add(customerId); setExpanded(next); }; return (
{isOpen && (
{projects.length > 0 ? ( ) : (
Nog geen projecten gekoppeld aan deze klant.
)}
)}
); })}
); }; const CustomerConfigCardLive = ({ row, isLast }) => { const customer = row.customer; const [customerEditOpen, setCustomerEditOpen] = useStateLive(false); const contactCount = customer.contacts.length; const contactLabel = `${contactCount} ${contactCount === 1 ? 'contactpersoon' : 'contactpersonen'}`; const customerAvatar = { naam: customer.naam, kleur: customer.kleur, initials: customer.naam.substring(0, 2).toUpperCase(), }; return (
{customer.naam}
{row.projectCount} projecten · {row.sauceCount} sauzen · {contactLabel}
{contactLabel} {row.projectCount} projecten {row.urgentCount > 0 && ( {row.urgentCount} dringend )}
{customerEditOpen && ( setCustomerEditOpen(false)} onSaved={() => setCustomerEditOpen(false)} /> )}
); }; const KlantenConfigLive = ({ search = '' }) => { const customerRows = useCustomerRowsLive(); const [newOpen, setNewOpen] = useStateLive(false); const filteredRows = useMemoLive(() => { const term = String(search || '').trim().toLowerCase(); if (!term) return customerRows; return customerRows.filter((row) => { const customer = row.customer || {}; const contacts = customer.contacts || []; const address = customer.address || {}; const projects = row.projects || []; const haystack = [ customer.id, customer.naam, address.address_line1, address.address_line2, address.city, address.pincode, address.country, ...contacts.flatMap((contact) => [ contact.naam, contact.email, contact.phone, contact.role, ]), ...projects.flatMap((project) => [ project.id, project.naam, ]), ] .filter(Boolean) .join(' ') .toLowerCase(); return haystack.includes(term); }); }, [customerRows, search]); return (
{filteredRows.length === 0 ? (
{search ? `Geen klanten gevonden voor "${search}".` : 'Nog geen klanten beschikbaar.'}
) : filteredRows.map((row, index) => ( ))}
{newOpen && setNewOpen(false)} onCreated={() => setNewOpen(false)} />}
); }; const UserConfigCardLive = ({ user, isLast }) => { const [userEditOpen, setUserEditOpen] = useStateLive(false); const avatar = { naam: user.naam, kleur: user.kleur, initials: user.initials, image: user.image, }; const roleLabel = user.roleLabel || user.rol || 'Geen rol'; return (
{user.naam}
{roleLabel}
{user.phone && ( Tel {user.phone} )} {user.mobile && ( Mobiel {user.mobile} )}
{userEditOpen && ( setUserEditOpen(false)} onSaved={() => setUserEditOpen(false)} /> )}
); }; const GebruikersConfigLive = ({ search = '' }) => { const [newOpen, setNewOpen] = useStateLive(false); const users = useMemoLive(() => ( [...window.GEBRUIKERS].sort((left, right) => ( String(left.naam || left.email || '').localeCompare(String(right.naam || right.email || ''), 'nl') )) ), [window.GEBRUIKERS]); const filteredUsers = useMemoLive(() => { const term = String(search || '').trim().toLowerCase(); if (!term) return users; return users.filter((user) => ( [ user.id, user.naam, user.email, user.phone, user.mobile, user.roleLabel, ] .filter(Boolean) .join(' ') .toLowerCase() .includes(term) )); }, [users, search]); return (
{filteredUsers.length === 0 ? (
{search ? `Geen gebruikers gevonden voor "${search}".` : 'Nog geen gebruikers beschikbaar.'}
) : filteredUsers.map((user, index) => ( ))}
{newOpen && setNewOpen(false)} onSaved={() => setNewOpen(false)} />}
); }; const ProjectTemplateModalLive = ({ template, onClose, onSaved }) => { const taskTypeOptions = useMemoLive(() => ( [...window.BUCKETS] .filter((bucket) => bucket.id !== '—') .sort((left, right) => ( (left.sequence || 0) - (right.sequence || 0) || String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), [window.BUCKETS]); const defaultTaskType = taskTypeOptions[0]?.naam || ''; const [name, setName] = useStateLive(template?.naam || ''); const [disabled, setDisabled] = useStateLive(Boolean(template?.disabled)); const [steps, setSteps] = useStateLive(() => { if (template?.steps?.length) { return template.steps.map((step) => ({ localId: makeDraftId(), taskId: step.taskId || step.taskName || '', taskType: step.taskType || '', name: step.name || '', durationDays: step.durationDays || 1, expectedHours: step.expectedHours || '', priority: step.priority || 'Medium', milestone: Boolean(step.milestone), description: step.description || '', })); } return [emptyTemplateStepDraft(defaultTaskType)]; }); const [busy, setBusy] = useStateLive(false); const [error, setError] = useStateLive(''); const preview = useMemoLive(() => ( buildTemplateSchedulePreview( { steps: steps.map((step) => ({ ...step, durationDays: Math.max(parseNumericInput(step.durationDays, 1), 1), expectedHours: Math.max(parseNumericInput(step.expectedHours, 0), 0), name: String(step.name || step.taskType || '').trim(), })), }, window.todayDate || new Date(), ) ), [steps]); const updateStep = (index, patch) => { setSteps((current) => current.map((step, itemIndex) => { if (itemIndex !== index) return step; const next = { ...step, ...patch }; if (patch.taskType && (!String(step.name || '').trim() || step.name === step.taskType)) { next.name = patch.taskType; } return next; })); }; const addStep = () => { setSteps((current) => [...current, emptyTemplateStepDraft(defaultTaskType)]); }; const moveStep = (index, direction) => { setSteps((current) => { const targetIndex = index + direction; if (targetIndex < 0 || targetIndex >= current.length) return current; const next = [...current]; const [item] = next.splice(index, 1); next.splice(targetIndex, 0, item); return next; }); }; const removeStep = (index) => { setSteps((current) => { if (current.length === 1) return [emptyTemplateStepDraft(defaultTaskType)]; return current.filter((_, itemIndex) => itemIndex !== index); }); }; const saveTemplate = async () => { if (!name.trim()) { setError('Vul een naam in voor de template.'); return; } const normalizedSteps = steps.map((step) => ({ taskId: step.taskId || '', taskType: String(step.taskType || '').trim(), name: String(step.name || '').trim(), durationDays: Math.max(Math.round(parseNumericInput(step.durationDays, 1)), 1), expectedHours: Math.max(parseNumericInput(step.expectedHours, 0), 0), priority: step.priority || 'Medium', milestone: Boolean(step.milestone), description: String(step.description || '').trim(), })); if (normalizedSteps.some((step) => !step.taskType)) { setError('Selecteer voor elke stap een taaktype.'); return; } if (normalizedSteps.some((step) => !step.name)) { setError('Vul voor elke stap een naam in.'); return; } setBusy(true); setError(''); try { const payload = { name: name.trim(), disabled, steps: normalizedSteps, }; if (template) { await window.dashboardApi.updateProjectTemplate(template.id, payload); } else { await window.dashboardApi.createProjectTemplate(payload); } if (onSaved) onSaved(); } catch (exc) { setError(exc.message || 'Template opslaan mislukt.'); } finally { setBusy(false); } }; const deleteTemplate = async () => { if (!template) return; if (!window.confirm(`Template "${template.naam}" verwijderen?`)) return; setBusy(true); setError(''); try { await window.dashboardApi.deleteProjectTemplate(template.id); if (onSaved) onSaved(); } catch (exc) { setError(exc.message || 'Template verwijderen mislukt.'); } finally { setBusy(false); } }; return ( <>
{template ? 'TEMPLATE BEWERKEN' : 'NIEUWE TEMPLATE'}

{template ? template.naam : 'Projecttemplate aanmaken'}

setName(event.target.value)} placeholder="bijv. Retail standaard" style={inputBaseStyle} />
{steps.map((step, index) => (
updateStep(index, { name: event.target.value })} placeholder="Naam van de stap" style={inputBaseStyle} /> updateStep(index, { durationDays: event.target.value })} placeholder="Dagen" style={inputBaseStyle} /> updateStep(index, { expectedHours: event.target.value })} placeholder="Uren" style={inputBaseStyle} />
updateStep(index, { description: event.target.value })} placeholder="Korte omschrijving voor deze stap" style={inputBaseStyle} />
))}
TEMPLATE PREVIEW
{preview?.stepCount || 0} {preview ? `${preview.durationDays} werkdagen` : '—'} {preview ? formatHoursLabel(preview.expectedHours) : '—'} {preview ? window.fmtDateLong(preview.end) : '—'}
AUTO-INVULLING
{(preview?.steps || []).map((step, index) => (
{step.name}
{step.taskType || 'Geen taaktype'} {step.milestone && ( Milestone )}
{window.fmtDate(step.start)} - {window.fmtDate(step.end)} · {step.durationDays} werkdag{step.durationDays === 1 ? '' : 'en'} {parseNumericInput(step.expectedHours, 0) > 0 && ` · ${formatHoursLabel(step.expectedHours)}`}
{step.description && (
{step.description}
)}
))}
{error && (
{error}
)}
{template ? ( ) : }
); }; const ProjectTemplateConfigCardLive = ({ template, isLast }) => { const [editOpen, setEditOpen] = useStateLive(false); const linkedProjects = window.PROJECTEN.filter((project) => project.projectTemplate === template.id).length; return (
{template.naam}
{!template.disabled ? ( Actief ) : ( Uitgeschakeld )}
{template.stepCount} stappen per saus · {template.durationDays} werkdagen · {formatHoursLabel(template.expectedHours)} · {linkedProjects} gekoppelde projecten
{template.steps.slice(0, 4).map((step) => ( {step.name} ))} {template.steps.length > 4 && ( +{template.steps.length - 4} meer )}
{editOpen && ( setEditOpen(false)} onSaved={() => setEditOpen(false)} /> )}
); }; const ProjectTemplatesConfigLive = ({ search = '' }) => { const [newOpen, setNewOpen] = useStateLive(false); const templates = useMemoLive(() => ( [...window.PROJECT_TEMPLATES].sort((left, right) => ( Number(Boolean(left.disabled)) - Number(Boolean(right.disabled)) || String(left.naam || left.id).localeCompare(String(right.naam || right.id), 'nl') )) ), [window.PROJECT_TEMPLATES]); const filteredTemplates = useMemoLive(() => { const term = String(search || '').trim().toLowerCase(); if (!term) return templates; return templates.filter((template) => ( [ template.id, template.naam, ...template.steps.flatMap((step) => [ step.name, step.taskType, step.description, ]), ] .filter(Boolean) .join(' ') .toLowerCase() .includes(term) )); }, [templates, search]); return (
Templates vullen bij nieuwe projecten automatisch alle stappen, duur en verwachte uren per saus in.
{filteredTemplates.length === 0 ? (
{search ? `Geen templates gevonden voor "${search}".` : 'Nog geen templates beschikbaar.'}
) : filteredTemplates.map((template, index) => ( ))}
{newOpen && ( setNewOpen(false)} onSaved={() => setNewOpen(false)} /> )}
); }; const KlantProjectTableLive = ({ projects, onPick }) => (
{projects.map((project) => (
onPick(project)} style={{ padding: '12px 4px', borderBottom: '1px solid var(--line)', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 8, }}>
{project.id} {project.naam} deadline {window.fmtDate(project.einde)}
{project.variants.map((variant) => { const bucket = window.bucketById(variant.bucketId); return (
{variant.naam}
{bucket.naam}
); })}
))}
); const LijstLive = ({ onPick }) => { const [sort, setSort] = useStateLive('deadline'); const projects = useMemoLive(() => { const items = [...window.PROJECTEN]; items.sort((left, right) => { if (sort === 'deadline') return (left.einde || '9999-12-31').localeCompare(right.einde || '9999-12-31'); if (sort === 'klant') return window.klantById(left.klant).naam.localeCompare(window.klantById(right.klant).naam); if (sort === 'prio') { const order = { dringend: 0, belangrijk: 1, gemiddeld: 2, laag: 3 }; return order[left.prio] - order[right.prio]; } return 0; }); return items; }, [sort, window.PROJECTEN]); return (
Sorteer: {['deadline', 'klant', 'prio'].map((item) => ( ))}
PROJECT ID NAAM KLANT HUIDIGE STAP PRIO TEAM DEADLINE
{projects.map((project) => { const customer = window.klantById(project.klant); const bucket = window.bucketById(project.huidigeBucket); return (
onPick(project)} style={{ display: 'grid', gridTemplateColumns: '98px 1.6fr 1fr 180px 110px 90px 120px', padding: '10px 16px', fontSize: 12.5, borderBottom: '1px solid var(--line)', cursor: 'pointer', alignItems: 'center', }} onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--surface-1)'; }} onMouseLeave={(event) => { event.currentTarget.style.background = 'var(--paper)'; }}> {project.id} {project.naam} {customer.naam} {bucket.naam}
); })}
); }; const TeamLive = () => { const workload = useMemoLive(() => { const map = {}; window.MEDEWERKERS.forEach((user) => { map[user.id] = emptyWorkloadStats(); }); window.ALL_TASKS.forEach((task) => { if (!task.owner || !map[task.owner]) return; const days = window.daysUntil(task.deadline); map[task.owner].open += 1; if (days < 0) map[task.owner].overdue += 1; if (days === 0) map[task.owner].vandaag += 1; if (days >= 0 && days <= 7) map[task.owner].deze_week += 1; }); return map; }, [window.ALL_TASKS, window.MEDEWERKERS]); const users = useMemoLive(() => { return window.MEDEWERKERS .map((user) => ({ ...user, displayName: displayTeamMemberName(user), workload: workload[user.id] || emptyWorkloadStats(), })) .sort((left, right) => ( right.workload.open - left.workload.open || left.displayName.localeCompare(right.displayName, 'nl') )); }, [workload, window.MEDEWERKERS]); const maxOpen = Math.max(...users.map((item) => item.workload.open), 1); const renderWorkloadBar = (stats, maxValue) => { const remaining = Math.max(stats.open - stats.overdue - stats.vandaag, 0); return (
{stats.overdue > 0 &&
} {stats.vandaag > 0 &&
} {remaining > 0 &&
}
); }; return (
{users.map((user, index) => (
{user.displayName}
{user.roleLabel || user.rol || 'Geen rol'}
{renderWorkloadBar(user.workload, maxOpen)}
{user.workload.open}
OPEN
{user.workload.overdue}
TE LAAT
{user.workload.deze_week}
DEZE WEEK
))}
); }; window.Sidebar = SidebarLive; window.Topbar = TopbarLive; window.NewProjectModal = NewProjectModalLive; window.ProjectDetail = ProjectDetailLive; window.MijnDag = MijnDagLive; window.PerKlantOverview = PerKlantOverviewLive; window.KlantenConfig = KlantenConfigLive; window.GebruikersConfig = GebruikersConfigLive; window.ProjectTemplatesConfig = ProjectTemplatesConfigLive; window.Lijst = LijstLive; window.Team = TeamLive;