SDK Reference complète
Référence exhaustive de toutes les APIs du Module SDK BizzOptima — types, capabilities et compatibilité.
Référence de @bizzoptima/module-sdk v1. Chaque namespace liste ses méthodes, la capability requise et les notes de compatibilité avec le desktop v0.1.0.
Les capabilities SDK 2.0 nécessitent au minimum signed_local. Les modules non signés n'ont accès qu'à
db:read et events:subscribe.Namespaces Core — toujours disponibles
api.db — Base de données SQLitedb:read / db:write
SQLite locale scopée au module. Chaque module gère ses propres tables. Les tables des autres modules ne sont pas accessibles.
db.querydb:read<T>(sql: string, params?: unknown[]) => Promise<T[]>Requête SELECT. Paramètres positionnels avec ?.
db.rundb:write(sql: string, params?: unknown[]) => Promise<{ changes: number; lastInsertRowid: number | bigint }>Cas réel — Module de gestion de contacts
// ─── Types ────────────────────────────────────────────────────────
interface Contact {
id: string;
name: string;
phone: string;
email: string;
note: string;
created_at: string;
}
// ─── Dans init() — migration DB ───────────────────────────────────
async function migrate(api: BizzOptimaModuleAPI) {
await api.db.run(`
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
note TEXT DEFAULT '',
created_at TEXT NOT NULL
)
`);
// Index pour la recherche rapide
await api.db.run(
'CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)',
);
}
// ─── Composant React ──────────────────────────────────────────────
function ContactList() {
const api = useModuleAPI();
const toast = useToast();
const [search, setSearch] = React.useState('');
// useDB se re-déclenche quand les params changent
const { data: contacts, loading, refetch } = useDB<Contact>(
'SELECT * FROM contacts WHERE name LIKE ? ORDER BY name ASC',
[`%${search}%`],
);
async function addContact(name: string, phone: string) {
await api.db.run(
'INSERT INTO contacts (id, name, phone, email, note, created_at) VALUES (?,?,?,?,?,?)',
[crypto.randomUUID(), name, phone, '', '', new Date().toISOString()],
);
toast('Contact ajouté', { type: 'success' });
refetch();
}
async function deleteContact(id: string) {
const ok = await api.ui.confirm('Supprimer ce contact ?');
if (!ok) return;
const result = await api.db.run('DELETE FROM contacts WHERE id = ?', [id]);
if (result.changes > 0) {
toast('Contact supprimé', { type: 'info' });
refetch();
}
}
if (loading) return <div>Chargement…</div>;
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher…" />
{contacts.map(c => (
<div key={c.id}>
<strong>{c.name}</strong> — {c.phone}
<button onClick={() => deleteContact(c.id)}>Supprimer</button>
</div>
))}
<button onClick={() => addContact('Nouveau Contact', '+237 6XX XXX XXX')}>
Ajouter
</button>
</div>
);
}api.events — Bus inter-modulesevents:emit / events:subscribe
Bus d'événements partagé entre tous les modules actifs. Permet à un module de réagir aux actions d'un autre sans couplage direct.
events.emitevents:emit(name: string, payload: unknown, options?) => Promise<void>options: { entityType?, entityId? } pour le log d'audit.
events.subscribeevents:subscribe(name: string, callback) => () => voidLa fonction retournée désinscrit l'écouteur.
Cas réel — Module de fidélité réagit aux ventes
// ─── Module "loyalty" — réagit aux ventes du module "sales" ────────
interface SaleCompletedPayload {
saleId: string;
customerId: string | null;
total: number;
currency: string;
items: Array<{ productId: string; qty: number; price: number }>;
}
// Dans init() du module loyalty :
function initLoyalty(api: BizzOptimaModuleAPI) {
// Créer la table de points
api.db.run(`
CREATE TABLE IF NOT EXISTS loyalty_points (
customer_id TEXT PRIMARY KEY,
points INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
`);
// S'abonner aux ventes terminées (émises par le module "sales")
const unsubscribe = api.events.subscribe(
'sale.completed',
async (raw: unknown) => {
const payload = raw as SaleCompletedPayload;
if (!payload.customerId) return; // vente anonyme → pas de points
// 1 point par tranche de 1000 XAF
const pointsEarned = Math.floor(payload.total / 1000);
if (pointsEarned === 0) return;
await api.db.run(`
INSERT INTO loyalty_points (customer_id, points, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(customer_id) DO UPDATE
SET points = points + excluded.points,
updated_at = excluded.updated_at
`, [payload.customerId, pointsEarned, new Date().toISOString()]);
const pts = pointsEarned === 1 ? '1 point' : pointsEarned + ' points';
api.ui.toast(
'+' + pts + ' de fidélité ajouté(s) !',
{ type: 'success', duration: 3000 },
);
},
);
return unsubscribe; // appelé quand le module est désactivé
}
// ─── Module "sales" — émet l'événement à chaque vente ──────────────
async function completeSale(api: BizzOptimaModuleAPI, sale: Sale) {
// ... logique de vente ...
// Émettre pour tous les modules abonnés
await api.events.emit('sale.completed', {
saleId: sale.id,
customerId: sale.customerId,
total: sale.total,
currency: api.config.getCurrency(),
items: sale.items,
}, {
entityType: 'sale',
entityId: sale.id,
});
}
// ─── Hook pour composant React ─────────────────────────────────────
function LoyaltyBadge({ customerId }: { customerId: string }) {
const [points, setPoints] = React.useState(0);
const api = useModuleAPI();
React.useEffect(() => {
api.db.query<{ points: number }>(
'SELECT points FROM loyalty_points WHERE customer_id = ?',
[customerId],
).then(rows => setPoints(rows[0]?.points ?? 0));
}, [customerId]);
// Mettre à jour en temps réel quand une vente arrive
useModuleEvents('sale.completed', () => {
api.db.query<{ points: number }>(
'SELECT points FROM loyalty_points WHERE customer_id = ?',
[customerId],
).then(rows => setPoints(rows[0]?.points ?? 0));
});
return <span>{points} pts</span>;
}api.config — Configuration businessToujours disponible
Snapshot de la configuration business. Valeurs lues depuis le profil configuré au premier lancement.
config.getBusiness() => BusinessConfigSnapshot{ businessId, businessName, currency, locale, activeModuleIds… }
config.getCurrency() => stringCode ISO 4217 : 'XAF', 'USD', 'EUR', 'GHS'…
config.getLocale() => 'fr' | 'en'config.getInstalledModules() => Promise<InstalledModuleInfo[]>Includes trustLevel for each installed module.
Cas réel — Affichage multi-devise et locale
// Composant qui s'adapte automatiquement à la locale et devise du business
function PriceDisplay({ amount }: { amount: number }) {
const locale = useLocale();
const currency = useCurrency();
// Formatage natif selon la locale du business
const formatted = new Intl.NumberFormat(
locale === 'fr' ? 'fr-CM' : 'en-US',
{ style: 'currency', currency, minimumFractionDigits: 0 },
).format(amount);
return <span className="font-mono font-semibold">{formatted}</span>;
}
// Composant qui liste les modules actifs pour l'inter-communication
function ModuleStatus() {
const api = useModuleAPI();
const business = useBusinessConfig();
const [modules, setModules] = React.useState<InstalledModuleInfo[]>([]);
React.useEffect(() => {
api.config.getInstalledModules().then(setModules);
}, []);
const hasInventory = business.activeModuleIds.includes('inventory');
const hasSales = business.activeModuleIds.includes('sales');
return (
<div>
<h3>{business.businessName}</h3>
<p>Devise : {business.currency} | Locale : {business.locale}</p>
{!hasInventory && (
<div className="warning">
Le module Inventaire n'est pas actif — certaines fonctionnalités sont limitées.
</div>
)}
<ul>
{modules.map(m => (
<li key={m.id}>
{m.name} v{m.version}
<Badge color={m.isActive ? 'emerald' : 'slate'}>
{m.isActive ? 'actif' : 'inactif'}
</Badge>
</li>
))}
</ul>
</div>
);
}api.ui — UI helpersToujours disponible
Toasts, dialogues de confirmation et alertes — rendu dans l'UI principale de BizzOptima, pas dans l'iframe du module.
ui.toast(message: string, options?: ToastOptions) => voidoptions: { type: 'success'|'error'|'warning'|'info', duration: number }
ui.confirm(message: string, title?: string) => Promise<boolean>Retourne true si l'utilisateur confirme.
ui.alert(message: string, title?: string) => voidCas réel — Flux de suppression avec confirmation
// Flux complet : action critique avec confirmation + feedback
async function deleteInvoice(api: BizzOptimaModuleAPI, invoiceId: string, invoiceNumber: string) {
// 1. Confirmation explicite
const confirmed = await api.ui.confirm(
`Supprimer la facture n°${invoiceNumber} ?
Cette action est irréversible.`,
'Confirmer la suppression',
);
if (!confirmed) return;
try {
// 2. Vérifier les dépendances (paiements associés)
const payments = await api.db.query(
'SELECT id FROM payments WHERE invoice_id = ? AND status = ?',
[invoiceId, 'completed'],
);
if (payments.length > 0) {
await api.ui.alert(
`Impossible de supprimer : ${payments.length} paiement(s) complété(s) sont liés à cette facture.`,
'Suppression bloquée',
);
return;
}
// 3. Suppression en cascade
await api.db.run('DELETE FROM invoice_lines WHERE invoice_id = ?', [invoiceId]);
await api.db.run('DELETE FROM invoices WHERE id = ?', [invoiceId]);
// 4. Notifier les autres modules
await api.events.emit('invoice.deleted', { invoiceId, invoiceNumber });
// 5. Feedback utilisateur
api.ui.toast(`Facture n°${invoiceNumber} supprimée`, { type: 'success' });
} catch (err) {
api.ui.toast('Erreur lors de la suppression', { type: 'error', duration: 5000 });
console.error('[invoicing] deleteInvoice error:', err);
}
}
// Hook useToast pour les composants React
function QuickActions({ invoiceId }: { invoiceId: string }) {
const toast = useToast();
const api = useModuleAPI();
const handleCopy = async () => {
const rows = await api.db.query<{ number: string }>(
'SELECT number FROM invoices WHERE id = ?', [invoiceId]
);
await api.clipboard.writeText(rows[0]?.number ?? '');
toast('Numéro copié dans le presse-papier', { type: 'info' });
};
return (
<div>
<button onClick={handleCopy}>Copier numéro</button>
<button onClick={() => deleteInvoice(api, invoiceId, '2024-0042')}>
Supprimer
</button>
</div>
);
}api.interModule — Communication sécuriséeToujours disponible
RPC typé entre modules actifs. Le module exposant peut restreindre qui peut l'appeler. Le module appelant ne connaît pas l'implémentation interne.
interModule.expose(method, handler, options?) => () => voidoptions.allowedCallers: moduleId[] — si absent, tout module peut appeler.
interModule.request<T>(targetId, method, params?) => Promise<T>Throw si la cible n'a pas exposé la méthode ou si le caller n'est pas autorisé.
Cas réel — Module Inventaire expose son stock au module Caisse
// ─── Module "inventory" — expose des méthodes ─────────────────────
interface StockCheckResult {
productId: string;
available: number;
reserved: number;
canSell: boolean;
}
// Dans init() du module inventory :
function initInventory(api: BizzOptimaModuleAPI) {
// Méthode accessible à tous les modules
api.interModule.expose(
'checkStock',
async (params: unknown): Promise<StockCheckResult> => {
const { productId, qty = 1 } = params as { productId: string; qty?: number };
const rows = await api.db.query<{ available: number; reserved: number }>(
'SELECT available, reserved FROM stock WHERE product_id = ?',
[productId],
);
const stock = rows[0] ?? { available: 0, reserved: 0 };
return {
productId,
available: stock.available,
reserved: stock.reserved,
canSell: stock.available - stock.reserved >= qty,
};
},
);
// Méthode sensible — réservation de stock
// Seuls les modules autorisés peuvent réserver
api.interModule.expose(
'reserveStock',
createSecureHandler(
async (params: unknown, caller) => {
const { productId, qty, orderId } = params as {
productId: string; qty: number; orderId: string
};
await api.db.run(
'UPDATE stock SET reserved = reserved + ? WHERE product_id = ?',
[qty, productId],
);
await api.db.run(
'INSERT INTO reservations (product_id, qty, order_id, reserved_by, created_at) VALUES (?,?,?,?,?)',
[productId, qty, orderId, caller.moduleId, new Date().toISOString()],
);
return { ok: true, reserved: qty };
},
{ moduleId: 'inventory', allowedCallers: ['quick_cashier', 'sales', 'orders'] },
),
);
}
// ─── Module "quick_cashier" — utilise l'inventaire ─────────────────
async function addToCart(api: BizzOptimaModuleAPI, productId: string, qty: number) {
// 1. Vérifier le stock avant d'ajouter au panier
const stock = await api.interModule.request<StockCheckResult>(
'inventory', // moduleId cible
'checkStock', // méthode exposée
{ productId, qty },
);
if (!stock.canSell) {
api.ui.toast(
`Stock insuffisant : ${stock.available} disponible(s), ${stock.reserved} réservé(s)`,
{ type: 'warning' },
);
return;
}
// 2. Réserver le stock
await api.interModule.request('inventory', 'reserveStock', {
productId,
qty,
orderId: `cart_${Date.now()}`,
});
// 3. Ajouter au panier local
await api.db.run(
'INSERT INTO cart_items (product_id, qty) VALUES (?,?)',
[productId, qty],
);
}
// ─── Hook React pour vérification en temps réel ────────────────────
function StockIndicator({ productId }: { productId: string }) {
const api = useModuleAPI();
const [stock, setStock] = React.useState<StockCheckResult | null>(null);
React.useEffect(() => {
api.interModule.request<StockCheckResult>('inventory', 'checkStock', { productId })
.then(setStock)
.catch(() => null); // inventory module peut ne pas être actif
}, [productId]);
if (!stock) return null;
return (
<span style={{ color: stock.canSell ? 'green' : 'red' }}>
{stock.available} en stock
</span>
);
}api.security — Contexte de sécuritéToujours disponible
Lecture seule. Retourne le niveau de confiance et les capabilities du module, disponibles dès l'initialisation.
security.getContext() => SecurityContext{ moduleId, trustLevel, capabilities, sessionToken }
security.getTrustLevel() => ModuleTrustLevelsecurity.hasCapability(cap: ModuleCapability) => booleansecurity.requireCapability(cap: ModuleCapability) => voidThrow CapabilityError si absent — fail fast avant toute opération sensible.
Cas réel — Feature gating par niveau de signature
import {
usePermission, usePermissions, useTrustLevel,
PermissionGuard, getMissingCapabilities, CapabilityError,
} from '@bizzoptima/module-sdk';
// ─── Composant avec feature gating ────────────────────────────────
function SyncPanel() {
const api = useModuleAPI();
const trustLevel = useTrustLevel();
const canHttp = usePermission('http:request');
const canFs = usePermission('fs:write');
const canAll = usePermissions(['http:request', 'fs:write', 'notifications:send']);
// Affichage d'un banner si le module n'est pas signé
if (trustLevel === 'unsigned_local') {
return (
<div className="upgrade-banner">
<p>Ce module n'est pas signé.</p>
<p>La synchronisation cloud nécessite un module signé (signed_local ou supérieur).</p>
<a href="https://bizzoptima.com/docs/module-sdk#signing">Signer mon module →</a>
</div>
);
}
return (
<div>
{/* Accès HTTP — signé local suffit */}
<PermissionGuard
api={api}
requires="http:request"
fallback={<p className="text-gray-500">Sync cloud non disponible (module non signé)</p>}
>
<SyncButton />
</PermissionGuard>
{/* Export CSV — nécessite fs:write en plus */}
<PermissionGuard
api={api}
requires={['http:request', 'fs:write']}
fallback={
<div>
<p>Export nécessite :</p>
<ul>
{getMissingCapabilities(api, ['http:request', 'fs:write']).map(cap => (
<li key={cap}>{cap}</li>
))}
</ul>
</div>
}
>
<ExportButton />
</PermissionGuard>
</div>
);
}
// ─── Handler sécurisé avec fail-fast ──────────────────────────────
async function exportAndSync(api: BizzOptimaModuleAPI) {
// Vérification explicite avant toute opération coûteuse
try {
api.security.requireCapability('fs:write');
api.security.requireCapability('http:request');
} catch (err) {
if (err instanceof CapabilityError) {
api.ui.alert(
`Capability manquante : ${err.capability}.
` +
`Niveau actuel : ${err.trustLevel}.
` +
`Signez le module pour débloquer cette fonctionnalité.`
);
return;
}
throw err;
}
// Ici on est sûr d'avoir les deux capabilities
const data = await api.db.query('SELECT * FROM sales WHERE synced = 0');
const json = JSON.stringify(data);
await api.fs.writeText('export/unsynced.json', json);
await api.http.post('https://erp.example.com/api/sync', data);
}api.settings — Paramètres persistéssettings:read / settings:write
Stockage clé/valeur persisté, scopé au module. Indépendant de la DB SQLite.
settings.getsettings:read(key: string) => Promise<string | null>settings.setsettings:write(key: string, value: string) => Promise<void>settings.deletesettings:write(key: string) => Promise<void>settings.getAllsettings:read() => Promise<Record<string, string>>// Lire un paramètre (hook)
const [erpUrl, setErpUrl, loading] = useSetting('erp_url', '');
// Impératif dans init()
const lastSync = await api.settings.get('last_erp_sync');
await api.settings.set('last_erp_sync', new Date().toISOString());
// Réinitialiser
await api.settings.delete('cache_key');api.navigatenavigate
navigatenavigate(route: string) => voidRoute relative à l'app BizzOptima.
// Dans un composant React
const navigate = useNavigate();
// Naviguer vers un autre module
navigate('/modules/inventory');
// Naviguer vers les paramètres de l'app
navigate('/settings');Namespaces SDK 2.0 — nécessitent un module signé
api.http — HTTP sans CORShttp:request
Toutes les requêtes passent par le main process Electron — pas de restriction CORS, suit le proxy système, supporte HTTPS auto-signé pour les APIs locales.
http.get<T>(url: string, headers?: Record<string,string>) => Promise<T>Parse automatiquement le JSON.
http.post<T>(url: string, body: unknown, headers?: Record<string,string>) => Promise<T>http.request(url: string, init?: HttpRequestInit) => Promise<HttpResponse>Accès bas niveau : status, headers, body brut.
Cas réel — Synchronisation avec une API externe (ERP, comptabilité)
// ─── Service de sync avec une API externe ─────────────────────────
interface ErpProduct {
sku: string;
name: string;
price: number;
stock: number;
}
interface SyncResult {
imported: number;
errors: string[];
lastSyncAt: string;
}
async function syncFromErp(api: BizzOptimaModuleAPI, erpUrl: string, apiKey: string): Promise<SyncResult> {
api.security.requireCapability('http:request');
const errors: string[] = [];
let imported = 0;
// 1. Récupérer le catalogue depuis l'ERP
let products: ErpProduct[];
try {
products = await api.http.get<ErpProduct[]>(
`${erpUrl}/api/products?format=bizzoptima`,
{ 'X-API-Key': apiKey, 'Accept': 'application/json' },
);
} catch (err) {
throw new Error(`Connexion ERP échouée : ${(err as Error).message}`);
}
// 2. Upsert dans la DB locale
for (const product of products) {
try {
await api.db.run(`
INSERT INTO products (sku, name, price, stock, synced_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(sku) DO UPDATE SET
name = excluded.name,
price = excluded.price,
stock = excluded.stock,
synced_at = excluded.synced_at
`, [product.sku, product.name, product.price, product.stock, new Date().toISOString()]);
imported++;
} catch (err) {
errors.push(`[SKU ${product.sku}] ${(err as Error).message}`);
}
}
const lastSyncAt = new Date().toISOString();
await api.settings.set('last_erp_sync', lastSyncAt);
return { imported, errors, lastSyncAt };
}
// ─── Composant React avec état de sync ────────────────────────────
function ErpSyncPanel() {
const api = useModuleAPI();
const { online } = useNetwork();
const [syncing, setSyncing] = React.useState(false);
const [result, setResult] = React.useState<SyncResult | null>(null);
// Lire la config de l'ERP depuis les settings
const [erpUrl] = useSetting('erp_url', '');
const [apiKey] = useSetting('erp_api_key', '');
async function handleSync() {
if (!online) {
api.ui.toast('Pas de connexion internet', { type: 'warning' });
return;
}
if (!erpUrl) {
api.ui.alert('Configurez l'URL de votre ERP dans les paramètres.', 'Configuration requise');
return;
}
setSyncing(true);
try {
const res = await syncFromErp(api, erpUrl, apiKey);
setResult(res);
api.ui.toast(`${res.imported} produit(s) importé(s)`, { type: 'success' });
} catch (err) {
api.ui.toast((err as Error).message, { type: 'error' });
} finally {
setSyncing(false);
}
}
return (
<div>
<button onClick={handleSync} disabled={syncing || !online}>
{syncing ? 'Synchronisation…' : 'Synchroniser depuis l'ERP'}
</button>
{!online && <span className="text-red-500">● Hors ligne</span>}
{result && (
<div>
<p>{result.imported} produits importés — {result.lastSyncAt}</p>
{result.errors.map((e, i) => <p key={i} className="text-red-500">{e}</p>)}
</div>
)}
</div>
);
}
// ─── Hook useHttp pour fetch simple ────────────────────────────────
function ExchangeRates() {
const { data: rates, loading, error } = useHttp<{ XAF: number; USD: number }>(
'https://api.exchangerate.host/latest?base=EUR&symbols=XAF,USD',
);
if (loading) return <p>Chargement des taux…</p>;
if (error || !rates) return <p>Taux indisponibles</p>;
return (
<div>
<p>1 EUR = {rates.XAF} XAF</p>
<p>1 EUR = {rates.USD} USD</p>
</div>
);
}api.fs — Système de fichiers sandboxéfs:read / fs:write / fs:dialog
Accès restreint au dossier userData/module-data/{moduleId}/. Toute écriture est isolée — un module ne peut pas lire les fichiers d'un autre.
fs.readText / writeText(path: string, content?: string) => Promise<string|null|void>fs.readBase64 / writeBase64(path: string, data?: string) => Promise<string|null|void>Pour les images, PDF et binaires.
fs.list(directory?: string) => Promise<FsFileInfo[]>FsFileInfo: { name, path, size, modifiedAt, isDirectory }
fs.exists / delete(path: string) => Promise<boolean|void>fs.pickFilefs:dialog(filters?) => Promise<string|null>Ouvre le sélecteur de fichiers OS natif.
Cas réel — Export des ventes en CSV
// ─── Export CSV complet ────────────────────────────────────────────
interface SaleRow {
id: string;
date: string;
customer_name: string;
total: number;
payment_method: string;
}
function escCsv(val: string | number): string {
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
async function exportSalesToCsv(
api: BizzOptimaModuleAPI,
fromDate: string,
toDate: string,
): Promise<void> {
api.security.requireCapability('fs:write');
// 1. Requête des données
const sales = await api.db.query<SaleRow>(`
SELECT s.id, s.created_at as date, c.name as customer_name,
s.total, s.payment_method
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.created_at BETWEEN ? AND ?
ORDER BY s.created_at DESC
`, [fromDate, toDate]);
if (sales.length === 0) {
api.ui.alert('Aucune vente sur la période sélectionnée.');
return;
}
// 2. Construire le CSV
const headers = ['ID', 'Date', 'Client', 'Total', 'Paiement'];
const rows = sales.map(s => [
escCsv(s.id),
escCsv(s.date.slice(0, 10)),
escCsv(s.customer_name ?? 'Anonyme'),
escCsv(s.total),
escCsv(s.payment_method),
].join(','));
const csv = [headers.join(','), ...rows].join('\n');
// 3. Écrire dans le sandbox du module
const fileName = `exports/sales_${fromDate.slice(0,10)}_to_${toDate.slice(0,10)}.csv`;
await api.fs.writeText(fileName, csv);
// 4. Notification + ouverture du dossier
api.ui.toast(`Export créé : ${sales.length} lignes`, { type: 'success' });
// Ouvrir le fichier dans l'explorateur (shell:open requis)
// api.shell.openPath(fileName);
}
// ─── Import depuis fichier utilisateur ────────────────────────────
async function importFromCsv(api: BizzOptimaModuleAPI) {
// Ouvre le sélecteur de fichier natif
const filePath = await api.fs.pickFile([
{ name: 'CSV Files', extensions: ['csv'] },
{ name: 'Excel Files', extensions: ['xlsx', 'xls'] },
]);
if (!filePath) return; // annulé par l'utilisateur
// Note: le filePath ici est externe au sandbox du module.
// Pour lire un fichier externe, il faut passer par api.http ou
// utiliser le chemin retourné par le dialog directement.
api.ui.toast(`Fichier sélectionné : ${filePath}`, { type: 'info' });
}
// ─── Gestion d'un répertoire d'exports ────────────────────────────
async function listExports(api: BizzOptimaModuleAPI) {
const files = await api.fs.list('exports');
// files: FsFileInfo[]
return files
.filter(f => !f.isDirectory && f.name.endsWith('.csv'))
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}
// ─── Composant de gestion des exports ─────────────────────────────
function ExportManager() {
const api = useModuleAPI();
const canExport = usePermissions(['fs:read', 'fs:write']);
const [exports, setExports] = React.useState<FsFileInfo[]>([]);
React.useEffect(() => {
if (canExport) {
listExports(api).then(setExports);
}
}, [canExport]);
if (!canExport) {
return <p>Export non disponible (module non signé)</p>;
}
return (
<div>
<button onClick={() => exportSalesToCsv(api, '2024-01-01', '2024-12-31')}>
Exporter les ventes 2024
</button>
<h3>Exports existants</h3>
{exports.map(f => (
<div key={f.path}>
{f.name} — {(f.size / 1024).toFixed(1)} ko
<button onClick={() => api.fs.delete(f.path)}>Supprimer</button>
</div>
))}
</div>
);
}api.scheduler — Planificateur de tâchesscheduler:manage
Jobs persistés en SQLite (survivent aux redémarrages). Une tâche se déclenche même si le module était inactif quand l'heure est venue.
scheduler.once(id: string, delayMs: number, callback: () => void) => ScheduledJobscheduler.interval(id: string, intervalMs: number, callback: () => void) => ScheduledJobscheduler.daily(id: string, time: string, callback: () => void) => ScheduledJobtime format: 'HH:MM'
scheduler.cancel(id: string) => voidscheduler.onFired(callback: (jobId: string) => void) => () => voidCas réel — Rapport quotidien + alerte stock
// Dans init() — configurer les tâches planifiées
function initScheduler(api: BizzOptimaModuleAPI) {
// 1. Rapport quotidien à 23h00
api.scheduler.daily('daily-report', '23:00', async () => {
const today = new Date().toISOString().slice(0, 10);
const sales = await api.db.query<{ total: number; count: number }>(
'SELECT SUM(total) as total, COUNT(*) as count FROM sales WHERE DATE(created_at) = ?',
[today],
);
const { total = 0, count = 0 } = sales[0] ?? {};
// Écrire le rapport en JSON
const report = { date: today, total, count, generatedAt: new Date().toISOString() };
await api.fs.writeText(
`reports/daily/${today}.json`,
JSON.stringify(report, null, 2),
);
// Notifier l'utilisateur
api.notifications.send(
'Rapport journalier généré',
`${count} vente(s) · Total : ${total.toLocaleString()} XAF`,
);
});
// 2. Vérification du stock toutes les 2h
api.scheduler.interval('stock-check', 2 * 60 * 60 * 1000, async () => {
const lowStock = await api.db.query<{ name: string; available: number; min_stock: number }>(
'SELECT name, available, min_stock FROM products WHERE available <= min_stock AND min_stock > 0',
);
if (lowStock.length > 0) {
api.notifications.send(
`⚠️ ${lowStock.length} produit(s) en rupture imminente`,
lowStock.slice(0, 3).map(p => `${p.name}: ${p.available} restants`).join('\n'),
);
}
});
// 3. Backup auto 5 minutes après ouverture de l'app
api.scheduler.once('startup-backup', 5 * 60 * 1000, async () => {
const allData = await api.db.query('SELECT * FROM sales ORDER BY created_at DESC LIMIT 1000');
await api.fs.writeText('backups/latest.json', JSON.stringify(allData));
});
// 4. Écouter les déclenchements (pour logs)
api.scheduler.onFired((jobId) => {
console.log(`[scheduler] Job déclenché : ${jobId} à ${new Date().toISOString()}`);
});
}
// ─── Composant de gestion des tâches planifiées ─────────────────
function SchedulerPanel() {
const api = useModuleAPI();
const [jobs, setJobs] = React.useState<ScheduledJob[]>([]);
React.useEffect(() => {
setJobs(api.scheduler.list());
}, []);
return (
<div>
<h3>Tâches planifiées</h3>
{jobs.map(job => (
<div key={job.id}>
<strong>{job.id}</strong> — {job.type}
{job.nextRunAt && <span> · Prochain : {new Date(job.nextRunAt).toLocaleString()}</span>}
<button onClick={() => { api.scheduler.cancel(job.id); setJobs(api.scheduler.list()); }}>
Annuler
</button>
</div>
))}
</div>
);
}api.notifications — Notifications OSnotifications:send
Notifications natives du système d'exploitation (Windows toast, macOS banner). Apparaissent même quand BizzOptima est en arrière-plan.
notifications.send(title: string, body: string, options?: { icon?: string; silent?: boolean }) => voidCas réel — Alerte multi-niveaux pour le stock
// ─── Système d'alertes avec niveaux de priorité ─────────────────
interface StockAlert {
productId: string;
name: string;
available: number;
minStock: number;
level: 'critical' | 'warning' | 'info';
}
async function checkAndNotifyStock(api: BizzOptimaModuleAPI) {
api.security.requireCapability('notifications:send');
const lowStock = await api.db.query<Omit<StockAlert, 'level'>>(`
SELECT product_id as productId, name, available, min_stock as minStock
FROM products
WHERE available <= min_stock
ORDER BY (available / CAST(min_stock AS REAL)) ASC
LIMIT 20
`);
if (lowStock.length === 0) return;
// Classer par niveau de criticité
const critical = lowStock.filter(p => p.available === 0);
const warning = lowStock.filter(p => p.available > 0 && p.available <= p.minStock / 2);
const info = lowStock.filter(p => p.available > p.minStock / 2);
// Notification groupée par niveau
if (critical.length > 0) {
api.notifications.send(
`🚨 ${critical.length} produit(s) en rupture totale`,
critical.slice(0, 3).map(p => `❌ ${p.name} : 0 unité`).join('\n'),
{ silent: false }, // son activé pour les alertes critiques
);
}
if (warning.length > 0) {
api.notifications.send(
`⚠️ ${warning.length} produit(s) en stock critique`,
warning.slice(0, 3).map(p => `${p.name} : ${p.available}/${p.minStock} min`).join('\n'),
{ silent: true },
);
}
if (info.length > 0 && critical.length === 0) {
// Ne pas spammer si des alertes critiques sont déjà envoyées
api.notifications.send(
`ℹ️ ${info.length} produit(s) sous le seuil`,
`Vérifiez votre stock.`,
{ silent: true },
);
}
}api.print — Impressionprint:document
Impression via le moteur Chromium d'Electron. Supporte le CSS complet, les media queries @print et les imprimantes thermiques.
print.htmlprint:document(html: string) => Promise<void>Génère un document HTML complet et l'imprime silencieusement.
print.receiptprint:document(data: ReceiptData) => Promise<void>v0.2.0 — template reçu thermique 58mm/80mm.
Cas réel — Génération et impression d'une facture
// ─── Génération de facture HTML + impression ─────────────────────
interface InvoiceLine {
description: string;
qty: number;
unitPrice: number;
total: number;
}
interface Invoice {
number: string;
date: string;
customerName: string;
customerEmail: string;
lines: InvoiceLine[];
subtotal: number;
tax: number;
total: number;
currency: string;
}
function buildInvoiceHtml(invoice: Invoice, businessName: string): string {
const fmt = (n: number) => n.toLocaleString('fr-FR') + ' ' + invoice.currency;
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Facture ${invoice.number}</title>
<style>
@page { margin: 15mm; size: A4; }
body { font-family: Arial, sans-serif; font-size: 12px; color: #1a1a1a; }
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
.business { font-size: 20px; font-weight: bold; color: #059669; }
.invoice-number { font-size: 24px; font-weight: bold; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #059669; color: white; padding: 8px 12px; text-align: left; }
td { padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
.totals { margin-top: 20px; text-align: right; }
.total-final { font-size: 18px; font-weight: bold; color: #059669; }
@media print { button { display: none; } }
</style>
</head>
<body>
<div class="header">
<div>
<div class="business">${businessName}</div>
<div>Facture n°${invoice.number}</div>
<div>Date : ${new Date(invoice.date).toLocaleDateString('fr-FR')}</div>
</div>
<div>
<div><strong>Client</strong></div>
<div>${invoice.customerName}</div>
<div>${invoice.customerEmail}</div>
</div>
</div>
<table>
<thead>
<tr><th>Description</th><th>Qté</th><th>Prix unitaire</th><th>Total</th></tr>
</thead>
<tbody>
${invoice.lines.map(l => `
<tr>
<td>${l.description}</td>
<td>${l.qty}</td>
<td>${fmt(l.unitPrice)}</td>
<td>${fmt(l.total)}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="totals">
<p>Sous-total : ${fmt(invoice.subtotal)}</p>
<p>TVA (19.25%) : ${fmt(invoice.tax)}</p>
<p class="total-final">TOTAL : ${fmt(invoice.total)}</p>
</div>
</body>
</html>`;
}
async function printInvoice(api: BizzOptimaModuleAPI, invoiceId: string) {
api.security.requireCapability('print:document');
// 1. Charger la facture
const rows = await api.db.query<InvoiceLine & { invoice_number: string; customer_name: string }>(
`SELECT i.number as invoice_number, c.name as customer_name,
il.description, il.qty, il.unit_price as unitPrice, il.total
FROM invoices i
JOIN customers c ON i.customer_id = c.id
JOIN invoice_lines il ON il.invoice_id = i.id
WHERE i.id = ?`,
[invoiceId],
);
if (rows.length === 0) {
api.ui.toast('Facture introuvable', { type: 'error' });
return;
}
const subtotal = rows.reduce((s, r) => s + r.total, 0);
const tax = subtotal * 0.1925;
const invoice: Invoice = {
number: rows[0].invoice_number,
date: new Date().toISOString(),
customerName: rows[0].customer_name,
customerEmail: '',
lines: rows.map(r => ({ description: r.description, qty: r.qty, unitPrice: r.unitPrice, total: r.total })),
subtotal,
tax,
total: subtotal + tax,
currency: api.config.getCurrency(),
};
const html = buildInvoiceHtml(invoice, api.config.getBusiness().businessName);
await api.print.html(html);
api.ui.toast('Facture envoyée à l'imprimante', { type: 'success' });
}api.network · api.clipboard · api.shellDivers
network — Détection hors-ligne avec bannière
// Composant offline-aware — affiche une bannière quand hors ligne
function OfflineAwareApp() {
const { online } = useNetwork();
return (
<div>
{!online && (
<div className="offline-banner" style={{ background: '#fef2f2', padding: '8px 16px', borderBottom: '1px solid #fca5a5' }}>
⚠️ Vous êtes hors ligne. Les données cloud ne seront pas synchronisées.
</div>
)}
<MainContent />
</div>
);
}
// Utilisation impérative
function SyncButton() {
const api = useModuleAPI();
const { online } = useNetwork();
const handleSync = async () => {
if (!api.network.isOnline()) {
api.ui.toast('Hors ligne — sync impossible', { type: 'warning' });
return;
}
// ... sync logic
};
return <button disabled={!online} onClick={handleSync}>Synchroniser</button>;
}clipboard — Copie d'informations critiques
// Copier un code de transaction ou numéro de facture
async function copyTransactionId(api: BizzOptimaModuleAPI, txId: string) {
api.security.requireCapability('clipboard:write');
await api.clipboard.writeText(txId);
api.ui.toast('Référence copiée : ' + txId, { type: 'info', duration: 2000 });
}
// Lire le presse-papier pour coller un code EAN
async function pasteBarcode(api: BizzOptimaModuleAPI) {
api.security.requireCapability('clipboard:read');
const barcode = await api.clipboard.readText();
if (!barcode || !/^[0-9]{8,13}$/.test(barcode)) {
api.ui.toast('Contenu du presse-papier non reconnu comme code-barres', { type: 'warning' });
return null;
}
return barcode;
}shell — Ouvrir un PDF généré
// Après avoir écrit un PDF ou CSV, l'ouvrir dans l'app par défaut
async function generateAndOpen(api: BizzOptimaModuleAPI) {
api.security.requireCapability('shell:open');
// Générer le rapport
const csv = '...' // contenu généré
const filePath = 'exports/rapport-2024.csv';
await api.fs.writeText(filePath, csv);
// Ouvrir avec l'app par défaut (Excel, LibreOffice…)
api.shell.openPath(filePath);
// Ou ouvrir un lien externe dans le navigateur
api.shell.openUrl('https://docs.bizzoptima.com/modules');
}api.agent — Agent IA Xpressagent:run
Agent IA avec tool calling. Les outils permettent à l'IA d'interroger la DB, appeler des APIs et effectuer des actions. Disponible uniquement si Xpress AI est configuré.
agent.isAvailable() => booleanVérifie si Xpress AI est configuré sur ce poste.
agent.runagent:run(options: AgentRunOptions) => Promise<AgentRunResult>Cas réel — Analyse des ventes par commande naturelle
// ─── Agent avec outils DB + HTTP ─────────────────────────────────
async function analyzeSalesWithAI(api: BizzOptimaModuleAPI, question: string): Promise<string> {
api.security.requireCapability('agent:run');
if (!api.agent.isAvailable()) {
return 'Xpress AI n'est pas configuré sur ce poste.';
}
const result = await api.agent.run({
systemPrompt: `Tu es un assistant d'analyse commerciale pour ${api.config.getBusiness().businessName}.
Tu as accès aux données de ventes. Réponds en français, de manière concise.
Utilise toujours les outils pour avoir des données à jour avant de répondre.`,
prompt: question,
maxSteps: 8,
tools: [
{
name: 'get_sales_summary',
description: 'Résumé des ventes pour une période donnée (total, nombre, panier moyen)',
parameters: {
from_date: { type: 'string', description: 'Date de début YYYY-MM-DD' },
to_date: { type: 'string', description: 'Date de fin YYYY-MM-DD' },
},
handler: async ({ from_date, to_date }) => {
return api.db.query(`
SELECT
COUNT(*) as count,
SUM(total) as total_revenue,
AVG(total) as avg_basket,
MAX(total) as max_sale,
MIN(total) as min_sale
FROM sales
WHERE DATE(created_at) BETWEEN ? AND ?
`, [from_date, to_date]);
},
},
{
name: 'get_top_products',
description: 'Produits les plus vendus sur une période',
parameters: {
from_date: { type: 'string' },
to_date: { type: 'string' },
limit: { type: 'number', description: 'Nombre de produits à retourner (défaut: 5)' },
},
handler: async ({ from_date, to_date, limit = 5 }) => {
return api.db.query(`
SELECT p.name, SUM(si.qty) as total_qty, SUM(si.total) as revenue
FROM sale_items si
JOIN products p ON si.product_id = p.id
JOIN sales s ON si.sale_id = s.id
WHERE DATE(s.created_at) BETWEEN ? AND ?
GROUP BY p.id
ORDER BY revenue DESC
LIMIT ?
`, [from_date, to_date, limit]);
},
},
{
name: 'get_low_stock_products',
description: 'Produits dont le stock est bas ou épuisé',
parameters: {},
handler: async () => {
return api.db.query(`
SELECT name, available, min_stock
FROM products
WHERE available <= min_stock
ORDER BY available ASC
LIMIT 10
`);
},
},
],
});
return result.output;
}
// ─── Composant React avec interface de chat ────────────────────────
function AISalesAssistant() {
const api = useModuleAPI();
const canUseAI = usePermission('agent:run');
const [question, setQuestion] = React.useState('');
const [answer, setAnswer] = React.useState('');
const [loading, setLoading] = React.useState(false);
if (!canUseAI || !api.agent.isAvailable()) {
return <p>Xpress AI non disponible sur ce poste.</p>;
}
const handleAsk = async () => {
if (!question.trim()) return;
setLoading(true);
setAnswer('');
try {
const res = await analyzeSalesWithAI(api, question);
setAnswer(res);
} catch (err) {
setAnswer('Erreur : ' + (err as Error).message);
} finally {
setLoading(false);
}
};
const suggestions = [
'Quelles sont les ventes d'aujourd'hui ?',
'Quels sont les 5 produits les plus vendus ce mois-ci ?',
'Y a-t-il des produits en rupture de stock ?',
];
return (
<div>
<div>
{suggestions.map(s => (
<button key={s} onClick={() => setQuestion(s)}>{s}</button>
))}
</div>
<textarea
value={question}
onChange={e => setQuestion(e.target.value)}
placeholder="Posez une question sur vos ventes…"
rows={3}
/>
<button onClick={handleAsk} disabled={loading}>
{loading ? 'Analyse en cours…' : 'Analyser'}
</button>
{answer && <div className="ai-answer">{answer}</div>}
</div>
);
}Hooks React
Hooks disponibles
useModuleAPI() => BizzOptimaModuleAPIAccède à l'instance API complète.
useBusinessConfig() => BusinessConfigSnapshotuseLocale() => 'fr' | 'en'useCurrency() => stringuseDB<T>(sql, params?) => { data, loading, error, refetch }useModuleEvents(eventName, callback) => voidAuto-unsubscribe on unmount.
useToast() => (message, options?) => voiduseNavigate() => (route) => voiduseSecurity() => SecurityContextuseTrustLevel() => ModuleTrustLeveluseCapabilities() => ModuleCapability[]usePermission(cap: ModuleCapability) => booleanusePermissions(caps: ModuleCapability[]) => booleanRetourne true si TOUTES les caps sont disponibles.
useNetwork() => { online: boolean }useHttp<T>(url, init?) => { data, loading, error, refetch }Requiert http:request.
useSetting(key, defaultValue?) => [value, setter, loading]Requiert settings:read/write.
Utilitaires de sécurité
Guards et helpers
PermissionGuard({ api, requires, fallback?, children }) => JSXHOC qui conditionne le rendu à une capability.
createSecureHandler(handler, options) => SecureHandlerWraps un handler inter-module avec vérification du caller.
createPaymentGuard(api, { allowedCallers? }) => (caller?) => voidGuard pour opérations de paiement sensibles.
assertCapabilities(api, capabilities[]) => voidThrow CapabilityError sur la première capability manquante.
getMissingCapabilities(api, required[]) => ModuleCapability[]Retourne les capabilities manquantes.
validateManifest(manifest) => { valid, errors[], warnings[] }Valide le manifest avant build.
// HOC conditionnel
<PermissionGuard api={api} requires="http:request" fallback={<p>Module non signé</p>}>
<SyncButton />
</PermissionGuard>
// Erreurs typées
import { CapabilityError, InterModuleAuthError } from '@bizzoptima/module-sdk';
try {
api.security.requireCapability('fs:write');
} catch (e) {
if (e instanceof CapabilityError) {
// e.capability, e.moduleId, e.trustLevel
}
}Capabilities par niveau de confiance
| Capability | unsigned | signed_local | signed_registry | trusted_bundled |
|---|---|---|---|---|
| db:read | ✅ | ✅ | ✅ | ✅ |
| db:write | — | ✅ | ✅ | ✅ |
| events:subscribe | ✅ | ✅ | ✅ | ✅ |
| events:emit | — | ✅ | ✅ | ✅ |
| navigate | — | ✅ | ✅ | ✅ |
| http:request | — | ✅ | ✅ | ✅ |
| fs:read / fs:write | — | ✅ | ✅ | ✅ |
| fs:dialog | — | — | ✅ | ✅ |
| scheduler:manage | — | ✅ | ✅ | ✅ |
| notifications:send | — | ✅ | ✅ | ✅ |
| print:document | — | — | ✅ | ✅ |
| clipboard:read | — | — | ✅ | ✅ |
| clipboard:write | — | ✅ | ✅ | ✅ |
| shell:open | — | ✅ | ✅ | ✅ |
| settings:read | — | — | ✅ | ✅ |
| settings:write | — | — | — | ✅ |