Flask + SQLite, in de Van Emous-huisstijl (tokens uit het design system, GSAP-effecten volgens de motion-regels, uit bij prefers-reduced-motion). - Leads en contactpersonen: status, toewijzen, verwachte waarde, omzetten naar klant, tijdlijn, bestanden, taken/opvolging - Relaties (zoals accounts in EspoCRM): klantbundels (huishouden/familie) en partners met type (leverancier, aannemer, ...); leveranciers van producten worden automatisch partners - API voor formulieren (POST /api/leads) met dubbele-leaddetectie, bestanden en configurator-ontwerpen (house-design JSON) - Import van CSV/JSON (leads, relaties, producten) met kolomkoppeling; configurator-formaat - Producten met SKU als vaste sleutel, mutatiehistorie, POST /api/products, CSV-export; offerteregels houden hun eigen prijs - Offertes met btw per tarief en afdrukversie (PDF via de browser) - Inbox via IMAP/SMTP, gekoppeld aan contacten - Gebruikers (beheerder/medewerker) en klantportaal (offertes accepteren, bestanden, berichten) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63 lines
3.2 KiB
JavaScript
63 lines
3.2 KiB
JavaScript
/* Effecten met GSAP, volgens de motion-regels van de Van Emous-huisstijl:
|
|
tekst fadet 28px omhoog, uitvoer cubic-bezier(0.22, 1, 0.36, 1), alles uit bij prefers-reduced-motion,
|
|
en na 3 s is alles hoe dan ook zichtbaar (zie de fx-wait-klasse in base.html). */
|
|
(() => {
|
|
const root = document.documentElement;
|
|
const reveal = () => root.classList.remove('fx-wait');
|
|
if (!window.gsap || matchMedia('(prefers-reduced-motion: reduce)').matches) return reveal();
|
|
|
|
gsap.registerEase?.('vanEmous', p => {
|
|
// cubic-bezier(0.22, 1, 0.36, 1) benaderd: zachte, lange uitloop
|
|
return 1 - Math.pow(1 - p, 4);
|
|
});
|
|
const ease = 'vanEmous';
|
|
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
|
|
const tl = gsap.timeline({ defaults: { ease, duration: 0.7 } });
|
|
|
|
// paginakop en meldingen
|
|
const head = document.querySelector('.head');
|
|
if (head) tl.from(head.children, { y: 28, autoAlpha: 0, stagger: 0.08 }, 0);
|
|
tl.from($$('.flash'), { y: -12, autoAlpha: 0, duration: 0.5 }, 0.05);
|
|
|
|
// tegels, kaarten en filters in een golf
|
|
const blocks = $$('.kpi, .filters, main .card, .portal-main .card, .auth .card, .auth > img');
|
|
tl.from(blocks.slice(0, 18), { y: 24, autoAlpha: 0, stagger: 0.05, clearProps: 'transform' }, 0.1);
|
|
|
|
// tabelrijen kort na hun kaart (alleen de eerste, de rest staat er meteen)
|
|
$$('.card tbody').forEach(tbody => {
|
|
const rows = $$(':scope > tr', tbody).slice(0, 20);
|
|
if (rows.length) tl.from(rows, { x: -8, autoAlpha: 0, duration: 0.45, stagger: 0.025 }, 0.3);
|
|
});
|
|
tl.from($$('.timeline li').slice(0, 12), { y: 12, autoAlpha: 0, duration: 0.5, stagger: 0.04 }, 0.3);
|
|
tl.from($$('.file, .member, .task').slice(0, 24), { y: 12, autoAlpha: 0, duration: 0.5, stagger: 0.03 }, 0.3);
|
|
|
|
// balkjes groeien vanaf links / onder
|
|
tl.from($$('.bar .fill'), { scaleX: 0, transformOrigin: '0 50%', duration: 1.1, stagger: 0.06 }, 0.35);
|
|
tl.from($$('.cols .c'), { scaleY: 0, transformOrigin: '50% 100%', duration: 0.9, stagger: 0.04 }, 0.35);
|
|
|
|
// kerncijfers tellen op (gehele getallen en eurobedragen)
|
|
const nl = (n, dec) => n.toLocaleString('nl-NL', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
|
$$('.kpi .value').forEach(el => {
|
|
const text = el.textContent.trim();
|
|
const euro = /^€\s?[\d.]+,\d{2}$/.test(text);
|
|
if (!euro && !/^\d+$/.test(text)) return;
|
|
const target = parseFloat(text.replace(/[€\s.]/g, '').replace(',', '.'));
|
|
if (!target) return;
|
|
const counter = { v: 0 };
|
|
tl.to(counter, {
|
|
v: target, duration: 1.2, ease: 'power2.out',
|
|
onUpdate: () => { el.textContent = euro ? '€ ' + nl(counter.v, 2) : nl(Math.round(counter.v), 0); },
|
|
onComplete: () => { el.textContent = text; },
|
|
}, 0.25);
|
|
});
|
|
|
|
reveal();
|
|
setTimeout(() => tl.progress(1), 3000);
|
|
|
|
// nieuwe offerteregel en geopende dialoog laten invliegen
|
|
document.addEventListener('crm:row', e => gsap.from(e.detail, { y: -8, autoAlpha: 0, duration: 0.4, ease }));
|
|
$$('dialog').forEach(d => new MutationObserver(() => {
|
|
if (d.open) gsap.fromTo(d, { y: 16, autoAlpha: 0, scale: 0.98 }, { y: 0, autoAlpha: 1, scale: 1, duration: 0.45, ease });
|
|
}).observe(d, { attributes: true, attributeFilter: ['open'] }));
|
|
})();
|