diff --git a/web/components.yml b/web/components.yml index caa8912..e387836 100644 --- a/web/components.yml +++ b/web/components.yml @@ -39,6 +39,7 @@ local_pages: - vpcs - subnets - vms + - account # Remote pages fetched from git — same mechanism as components. # @@ -68,6 +69,10 @@ menu: icon: vm children: - vms + - type: group + label: Comptes + icon: account + children: [] - type: separator - dashboard - type: section diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index dc105e7..ac49d79 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -32,13 +32,18 @@ function resolveIcon(name) { } class SideNav extends HTMLElement { - #items = [] - #expanded = new Set() // labels of open groups - #hamburger = null - #backdrop = null - #mqHandler = null - #hashHandler = null - #isMobile = false + #items = [] + #expanded = new Set() + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false + #readyResolve = null + + // Resolves once navigation.json is loaded and the nav is rendered. + // Await this before calling setGroupChildren(). + ready = new Promise(r => { this.#readyResolve = r }) async connectedCallback() { this.attachShadow({ mode: 'open' }) @@ -57,6 +62,7 @@ class SideNav extends HTMLElement { } this.#render() + this.#readyResolve() this.#mqHandler = () => this.#applyMode() this.#hashHandler = () => this.#updateActive() @@ -72,6 +78,17 @@ class SideNav extends HTMLElement { this.#removeBackdrop() } + // ── Public API ────────────────────────────────────────────────────────────── + + // Inject dynamic children into an existing group (identified by label). + // Call after awaiting nav.ready. + setGroupChildren(label, children) { + const group = this.#items.find(i => i.type === 'group' && i.label === label) + if (!group) return + group.children = children + this.#render() + } + // ── Rendu des items ───────────────────────────────────────────────────────── #currentHref() { diff --git a/web/core/menu.js b/web/core/menu.js new file mode 100644 index 0000000..0a2adad --- /dev/null +++ b/web/core/menu.js @@ -0,0 +1,37 @@ +// Dynamic menu loader. +// Fetches data for groups declared with empty children in navigation.json +// and injects them into side-nav after it is ready. +// +// Imported non-blocking from index.html — does not delay routing. +// Replace MOCK_* with real api-client calls when the backend is ready. + +// ── Mock data ──────────────────────────────────────────────────────────────── + +const MOCK_ACCOUNTS = [ + { id: 'tresorerie', name: 'Trésorerie' }, + { id: 'charges', name: 'Charges' }, + { id: 'produits', name: 'Produits' }, + { id: 'fournisseurs', name: 'Fournisseurs' }, +] + +async function fetchAccounts() { + // TODO: replace with real call + // const api = document.querySelector('api-client') + // return await api.finance.list('/accounts') + await new Promise(r => setTimeout(r, 500)) // simulate network latency + return MOCK_ACCOUNTS +} + +// ── Injection ──────────────────────────────────────────────────────────────── + +const nav = document.querySelector('side-nav') +if (!nav) throw new Error('menu.js: side-nav not found in DOM') + +await nav.ready + +const accounts = await fetchAccounts() +nav.setGroupChildren('Comptes', accounts.map(a => ({ + label: a.name, + href: `#/account?id=${a.id}`, + icon: 'account', +}))) diff --git a/web/core/router.js b/web/core/router.js index 06dbe9c..d23777c 100644 --- a/web/core/router.js +++ b/web/core/router.js @@ -17,7 +17,8 @@ const MODULE_CACHE = new Map() let activeCSS = null function currentRoute() { - return window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' + const hash = window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' + return hash.split('?')[0] } async function render(routeName) { diff --git a/web/index.html b/web/index.html index 021fb07..1af8dea 100644 --- a/web/index.html +++ b/web/index.html @@ -108,6 +108,9 @@ // then router renders the current route. await import('./core/registry.js') await import('./core/router.js') + + // Non-blocking: populates dynamic menu groups after routing. + import('./core/menu.js') diff --git a/web/pages/account/account.css b/web/pages/account/account.css new file mode 100644 index 0000000..b8eb4e4 --- /dev/null +++ b/web/pages/account/account.css @@ -0,0 +1,121 @@ +#account-page { + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ── Header ────────────────────────────────────────────────────────────── */ + +#account-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 10px; + padding: 20px 24px; +} + +#account-name { + font-size: 20px; + font-weight: 600; + color: #cdd6f4; + margin: 0 0 4px; +} + +#account-id { + font-size: 12px; + color: #6c7086; + font-family: monospace; +} + +#account-balance-block { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.balance-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; +} + +#account-balance { + font-size: 24px; + font-weight: 700; + color: #a6e3a1; + font-variant-numeric: tabular-nums; +} + +#account-balance.negative { color: #f38ba8; } + +/* ── Transactions ───────────────────────────────────────────────────────── */ + +#account-transactions { + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 10px; + padding: 20px 24px; +} + +#account-transactions h3 { + font-size: 13px; + font-weight: 600; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 16px; +} + +#tx-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +#tx-table th { + text-align: left; + color: #6c7086; + font-weight: 500; + font-size: 12px; + padding: 0 12px 10px; + border-bottom: 1px solid #313244; +} + +#tx-table td { + padding: 10px 12px; + color: #a6adc8; + border-bottom: 1px solid #1e1e2e; +} + +#tx-table tbody tr:last-child td { border-bottom: none; } +#tx-table tbody tr:hover td { background: #313244; } + +.amount-col { text-align: right; } + +td.amount { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; } +td.amount.positive { color: #a6e3a1; } +td.amount.negative { color: #f38ba8; } + +td.date { color: #6c7086; font-size: 13px; white-space: nowrap; } + +/* ── States ─────────────────────────────────────────────────────────────── */ + +#account-loading { + color: #6c7086; + font-size: 14px; +} + +#account-error { + background: #313244; + border: 1px solid #f38ba8; + border-radius: 8px; + padding: 12px 16px; + color: #f38ba8; + font-size: 13px; +} diff --git a/web/pages/account/account.js b/web/pages/account/account.js new file mode 100644 index 0000000..bed9af0 --- /dev/null +++ b/web/pages/account/account.js @@ -0,0 +1,117 @@ +// Account page. +// Reads ?id= from the URL hash and renders account details. +// Replace fetchAccount() with a real api-client call when backend is ready. + +// ── Mock data ──────────────────────────────────────────────────────────────── + +const MOCK_DB = { + tresorerie: { + name: 'Trésorerie', + balance: 48_230.50, + currency: 'EUR', + transactions: [ + { date: '2026-06-03', label: 'Virement client Martin SA', amount: +12_000.00 }, + { date: '2026-06-01', label: 'Virement client Dupont SARL', amount: +5_000.00 }, + { date: '2026-05-30', label: 'Loyer bureau juin', amount: -2_500.00 }, + { date: '2026-05-28', label: 'Facture EDF', amount: -340.20 }, + { date: '2026-05-25', label: 'Abonnement SaaS infra', amount: -189.00 }, + ], + }, + charges: { + name: 'Charges', + balance: 12_890.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-01', label: 'Salaires mai', amount: -8_500.00 }, + { date: '2026-05-28', label: 'Assurance professionnelle', amount: -420.00 }, + { date: '2026-05-20', label: 'Formation équipe', amount: -1_200.00 }, + ], + }, + produits: { + name: 'Produits', + balance: 67_450.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-02', label: 'Facture client #2024-089', amount: +18_000.00 }, + { date: '2026-05-29', label: 'Facture client #2024-088', amount: +9_500.00 }, + { date: '2026-05-22', label: 'Avoir client Dupont', amount: -500.00 }, + ], + }, + fournisseurs: { + name: 'Fournisseurs', + balance: -8_340.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-01', label: 'Facture Tech Components SAS', amount: -3_200.00 }, + { date: '2026-05-27', label: 'Règlement fournisseur Leroy', amount: +2_000.00 }, + { date: '2026-05-20', label: 'Facture Bureau & Co', amount: -640.00 }, + ], + }, +} + +async function fetchAccount(id) { + // TODO: replace with real call + // const api = document.querySelector('api-client') + // return await api.finance.get(`/accounts/${id}`) + await new Promise(r => setTimeout(r, 300)) + const data = MOCK_DB[id] + if (!data) throw new Error(`Compte introuvable : ${id}`) + return data +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatAmount(amount, currency) { + return new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(amount) +} + +function formatDate(iso) { + return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(iso)) +} + +// ── Init ───────────────────────────────────────────────────────────────────── + +export function init(main) { + const params = new URLSearchParams(window.location.hash.split('?')[1]) + const id = params.get('id') + + const loading = main.querySelector('#account-loading') + const error = main.querySelector('#account-error') + + if (!id) { + loading.style.display = 'none' + error.style.display = 'block' + error.textContent = 'Aucun compte sélectionné.' + return + } + + fetchAccount(id) + .then(data => render(main, id, data)) + .catch(err => { + loading.style.display = 'none' + error.style.display = 'block' + error.textContent = err.message + }) +} + +function render(main, id, data) { + main.querySelector('#account-loading').style.display = 'none' + + main.querySelector('#account-name').textContent = data.name + main.querySelector('#account-id').textContent = id + + const balanceEl = main.querySelector('#account-balance') + balanceEl.textContent = formatAmount(data.balance, data.currency) + balanceEl.classList.toggle('negative', data.balance < 0) + + const tbody = main.querySelector('#tx-body') + tbody.innerHTML = data.transactions.map(tx => ` + + ${formatDate(tx.date)} + ${tx.label} + + ${formatAmount(tx.amount, data.currency)} + + + `).join('') +} diff --git a/web/pages/account/index.html b/web/pages/account/index.html new file mode 100644 index 0000000..9d40142 --- /dev/null +++ b/web/pages/account/index.html @@ -0,0 +1,29 @@ +
+
+
+

+ +
+
+ Solde + +
+
+ +
+

Transactions récentes

+ + + + + + + + + +
DateLibelléMontant
+
+ + +
Chargement…
+
diff --git a/web/pages/account/manifest.json b/web/pages/account/manifest.json new file mode 100644 index 0000000..9226d9d --- /dev/null +++ b/web/pages/account/manifest.json @@ -0,0 +1,5 @@ +{ + "route": "account", + "label": "Compte", + "icon": "account" +}