Files
crm/static/fx.js
T
MachielandClaude Opus 5.5 bffa053c05 v0.0.1: eerste versie van de Van Emous CRM
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>
2026-10-14 21:56:55 +02:00

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'] }));
})();