diff --git a/.claude/skills/create-extension/SKILL.md b/.claude/skills/create-extension/SKILL.md new file mode 100644 index 00000000..784abd94 --- /dev/null +++ b/.claude/skills/create-extension/SKILL.md @@ -0,0 +1,127 @@ +--- +name: create-extension +description: "Generate and implement extensions for erp-base: scaffold files, configure manifests, write event handlers, API routes, services, workspace UIs, settings panels, and testing. Use when creating new extensions, adding surfaces to existing extensions, or understanding the extension architecture. Covers the full lifecycle from scaffolding to registration." +--- + +# Extension Generator + +## Quick Start + +**1. Scaffold:** +```bash +npx tsx scripts/create-extension.ts \ + --name my-extension --sector general --category operations \ + --description "Short description" +``` +Sectors: `general`, `restaurant`, `construction`, `hotel`, `tech`, `ecommerce`, `export` +Categories: `import`, `operations`, `reports`, `accounting` + +**2. Enable** — add `"my-extension"` to `extensions.config.json` + +**3. Regenerate** — `npm run setup:extensions` (auto-runs on `dev`/`build`) + +**4. Implement** — edit `index.ts` to add surfaces + +--- + +## Extension Object Template + +```typescript +import type { Extension, ExtensionContext } from '@/lib/extensions/types' +import type { EventPayload } from '@/lib/events/types' +import { myExtApiRoutes } from './api-routes' + +interface MyExtSettings { featureEnabled: boolean; threshold: number } +const DEFAULT_SETTINGS: MyExtSettings = { featureEnabled: true, threshold: 0.8 } + +async function handleSomeEvent( + payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext +): Promise { + const { transactions, userId } = payload + const log = ctx?.log ?? console + const settings = ctx + ? { ...DEFAULT_SETTINGS, ...(await ctx.settings.get>() || {}) } + : DEFAULT_SETTINGS + if (!settings.featureEnabled) return + + try { + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() + // ... business logic ... + } catch (error) { log.error('Handler failed:', error) } +} + +export const myExtensionExtension: Extension = { + id: 'my-extension', + name: 'My Extension', + version: '1.0.0', + sector: 'general', + apiRoutes: myExtApiRoutes, + eventHandlers: [ + { eventType: 'transaction.synced', handler: handleSomeEvent }, + ], + settingsPanel: { label: 'My Extension', path: '/settings/extensions/my-extension' }, + async onInstall(ctx) { await ctx.settings.set('settings', DEFAULT_SETTINGS) }, +} +``` + +Export name convention: `{camelCaseId}Extension` (e.g., `myExtensionExtension`). + +--- + +## Manifest (Minimal) + +```json +{ + "id": "my-extension", + "sector": "general", + "exportName": "myExtensionExtension", + "entryPoint": "@/extensions/general/my-extension", + "workspace": null, + "requiredEnvVars": [], + "optionalEnvVars": [], + "npmDependencies": [], + "definition": { + "name": "My Extension", + "category": "operations", + "icon": "Box", + "dataPattern": "core", + "description": "Short marketplace description", + "longDescription": "Longer detail page description." + } +} +``` + +For a workspace page, set `"workspace": "@/components/extensions/general/MyExtensionWorkspace"`. See **[Manifest Reference](references/manifest-format.md)**. + +## Extension Context + +```typescript +interface ExtensionContext { + userId: string; extensionId: string + supabase: SupabaseClient // Pre-authenticated + emit(event: CoreEvent): void // Publish core events + settings: ExtensionSettings // get(key?) / set(key, value) — stored in extension_data table + storage: ExtensionStorage // download / upload / getPublicUrl + log: ExtensionLogger // info / warn / error (scoped) + services: ExtensionServices // Core services (e.g., ingestTransactions) +} +``` + +## Available Surfaces + +| Surface | Reference | +|---------|-----------| +| `eventHandlers` — react to core events | [Event Handlers](references/event-handlers.md) | +| `apiRoutes` — HTTP endpoints | [API Routes](references/api-routes.md) | +| `services` — named functions for core | [Services](references/services-patterns.md) | +| `settingsPanel` / workspace UI | [Workspace & UI](references/workspace-ui.md) | +| `mappingRuleTypes`, `onInstall`/`onUninstall` | [Extension Interface](references/extension-interface.md) | + +## Common Mistakes + +1. **Forgetting `npm run setup:extensions`** after config change — extension won't load +2. **Editing `_generated/` files** — overwritten on next setup +3. **Not handling `ctx = undefined`** — context is undefined in cron jobs; always fallback +4. **Wrong export name** — must match `exportName` in manifest exactly +5. **Importing from other extensions** — only import from core (`@/lib/`, `@/types`) +6. **Lazy imports in module scope** — static imports for extension code; `await import()` only inside handler functions diff --git a/.claude/skills/create-extension/references/api-routes.md b/.claude/skills/create-extension/references/api-routes.md new file mode 100644 index 00000000..a612f53d --- /dev/null +++ b/.claude/skills/create-extension/references/api-routes.md @@ -0,0 +1,90 @@ +# API Routes Reference + +All extension APIs dispatch through `app/api/extensions/ext/[...path]/route.ts`. + +**URL scheme:** `/api/extensions/ext/{extensionId}/{routePath}` + +## Route Definition + +```typescript +import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types' +import { NextResponse } from 'next/server' + +export const myExtApiRoutes: ApiRouteDefinition[] = [ + { + method: 'GET', + path: '/', + handler: async (_req, ctx) => { + const { data, error } = await ctx!.supabase + .from('my_items').select('*') + .eq('user_id', ctx!.userId).order('created_at', { ascending: false }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ data }) + }, + }, +] +``` + +## Path Parameters + +Use `:paramName` — dispatcher extracts as `_paramName` search params: + +```typescript +{ + method: 'GET', + path: '/:id', + handler: async (req, ctx) => { + const id = new URL(req.url).searchParams.get('_id') + const { data } = await ctx!.supabase + .from('my_items').select('*').eq('id', id).eq('user_id', ctx!.userId).single() + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ data }) + }, +} +// Multiple params: path: '/:id/items/:itemId' → _id, _itemId +``` + +## Dispatcher Flow + +1. Extract `extensionId` and `routePath` from URL segments +2. Auth check (401) → toggle check via `isExtensionEnabled()` (403) → match method+path (404) +3. Extract path params → create `ExtensionContext` → call handler + +## Settings Route Pattern + +```typescript +{ method: 'GET', path: '/settings', + handler: async (_req, ctx) => { + const settings = await ctx!.settings.get() + return NextResponse.json({ data: settings ?? DEFAULT_SETTINGS }) + }, +}, +{ method: 'PUT', path: '/settings', + handler: async (req, ctx) => { + const body = await req.json() + const merged = { ...(await ctx!.settings.get() ?? DEFAULT_SETTINGS), ...body } + await ctx!.settings.set('settings', merged) + return NextResponse.json({ data: merged }) + }, +}, +``` + +## Response Conventions + +```typescript +NextResponse.json({ data: result }) // Success +NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) // Auth error +NextResponse.json({ error: 'Not found' }, { status: 404 }) // Not found +NextResponse.json({ error: error.message }, { status: 500 }) // Server error +``` + +## Frontend Calls + +```typescript +const res = await fetch('/api/extensions/ext/my-extension/') +const res = await fetch(`/api/extensions/ext/my-extension/${id}`) +const res = await fetch('/api/extensions/ext/my-extension/settings', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ featureEnabled: true }), +}) +``` diff --git a/.claude/skills/create-extension/references/event-handlers.md b/.claude/skills/create-extension/references/event-handlers.md new file mode 100644 index 00000000..c5f10611 --- /dev/null +++ b/.claude/skills/create-extension/references/event-handlers.md @@ -0,0 +1,75 @@ +# Event Handlers Reference + +Extensions subscribe via `eventHandlers`. The bus dispatches with `Promise.allSettled()` — failures are isolated. + +## Signature & Registration + +```typescript +async function handleSomeEvent( + payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext +): Promise { /* ... */ } + +export const myExtension: Extension = { + eventHandlers: [ + { eventType: 'transaction.synced', handler: handleSomeEvent }, + ], +} +``` + +`ctx` can be `undefined` in cron job contexts — always provide fallbacks. + +## All Event Types + +Source: `lib/events/types.ts`. Every payload includes `userId`. + +| Event | Payload | +|-------|---------| +| `journal_entry.drafted` | `{ entry: JournalEntry, userId }` | +| `journal_entry.committed` | `{ entry: JournalEntry, userId }` | +| `journal_entry.corrected` | `{ original, storno, corrected: JournalEntry, userId }` | +| `document.uploaded` | `{ document: DocumentAttachment, userId }` | +| `invoice.created` | `{ invoice: Invoice, userId }` | +| `invoice.sent` | `{ invoice: Invoice, userId }` | +| `credit_note.created` | `{ creditNote: CreditNote, userId }` | +| `transaction.synced` | `{ transactions: Transaction[], userId }` | +| `transaction.categorized` | `{ transaction, account, taxCode, userId }` | +| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` | +| `period.locked` | `{ period: FiscalPeriod, userId }` | +| `period.year_closed` | `{ period: FiscalPeriod, userId }` | +| `customer.created` | `{ customer: Customer, userId }` | +| `receipt.extracted` | `{ receipt, documentId, confidence, userId }` | +| `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` | +| `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` | +| `supplier_invoice.received` | `{ inboxItem: InvoiceInboxItem, userId }` | +| `supplier_invoice.extracted` | `{ inboxItem, confidence, userId }` | +| `supplier_invoice.confirmed` | `{ inboxItem, supplierInvoice, userId }` | + +## Standard Handler Pattern + +```typescript +async function handleTransactionSynced( + payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext +): Promise { + const { transactions, userId } = payload + const log = ctx?.log ?? console + const settings = ctx + ? { ...DEFAULT_SETTINGS, ...(await ctx.settings.get>() || {}) } + : await getSettings(userId) + + if (!settings.featureEnabled) return // Settings gate + + try { + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() + // Business logic... + await ctx?.emit({ type: 'receipt.matched', payload: { /* ... */ userId } }) + } catch (error) { log.error('Handler failed:', error) } +} +``` + +## Key Rules + +1. **Handle `ctx = undefined`** — fallback for supabase, logging, settings +2. **Handlers run concurrently** — order undefined, don't depend on other handlers +3. **Use settings gates** — let users control features +4. **Wrap in try/catch** — don't throw unhandled errors +5. **Emit events for cascading pipelines** — e.g., OCR emits `receipt.extracted` → triggers push notifications diff --git a/.claude/skills/create-extension/references/extension-interface.md b/.claude/skills/create-extension/references/extension-interface.md new file mode 100644 index 00000000..7f798df7 --- /dev/null +++ b/.claude/skills/create-extension/references/extension-interface.md @@ -0,0 +1,96 @@ +# Extension Interface Reference + +Source: `lib/extensions/types.ts` + +## Extension Interface + +```typescript +interface Extension { + id: string; name: string; version: string; sector?: SectorSlug + routes?: RouteDefinition[] + apiRoutes?: ApiRouteDefinition[] + sidebarItems?: SidebarItem[] + eventHandlers?: ExtensionEventHandler[] + mappingRuleTypes?: MappingRuleTypeDefinition[] + reportTypes?: ReportDefinition[] + settingsPanel?: SettingsPanelDefinition + taxCodes?: TaxCodeDefinition[] + dimensionTypes?: DimensionDefinition[] + services?: Record Promise> + onInstall?(ctx: ExtensionContext): Promise + onUninstall?(ctx: ExtensionContext): Promise +} +``` + +All surfaces are optional. An extension can provide any combination. + +## Key Supporting Types + +```typescript +interface ApiRouteDefinition { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + path: string // e.g., "/:id/confirm" + handler: (request: Request, ctx?: ExtensionContext) => Promise +} + +interface ExtensionEventHandler { + eventType: CoreEventType + handler: (payload: any, ctx?: ExtensionContext) => Promise | void +} + +interface SidebarItem { label: string; icon?: string; path: string; order?: number } +interface SettingsPanelDefinition { label: string; path: string } +interface MappingRuleTypeDefinition { id: string; name: string; description: string } +interface RouteDefinition { path: string; label: string } +``` + +## ExtensionContext + +```typescript +interface ExtensionContext { + userId: string; extensionId: string; supabase: SupabaseClient + emit(event: CoreEvent): Promise + settings: { get(key?: string): Promise; set(key: string, value: T): Promise } + storage: { download(bucket, path); upload(bucket, path, data, options?); getPublicUrl(bucket, path) } + log: { info(msg, ...args); warn(msg, ...args); error(msg, ...args) } // Prefixed ext:{id} + services: { ingestTransactions(supabase, userId, raw): Promise } +} +``` + +Settings stored in `extension_data` table with composite key `(user_id, extension_id, key)`. + +## Complexity Spectrum + +**Level 1 — Pure UI** (no surfaces, workspace reads core data): +```typescript +export const calendarExtension: Extension = { id: 'calendar', name: 'Kalender', version: '1.0.0' } +``` + +**Level 2 — Event handler only:** +```typescript +export const loggerExtension: Extension = { + id: 'example-logger', name: 'Logger', version: '1.0.0', + eventHandlers: [{ eventType: 'journal_entry.committed', handler: handleCommitted }], +} +``` + +**Level 3 — Service provider** (registers at module load): +```typescript +registerEmailService(new ResendEmailService()) +export const emailExtension: Extension = { id: 'email', name: 'Email', version: '1.0.0' } +``` + +**Level 4 — Full extension** (events + API + settings + mappingRules + onInstall): +```typescript +export const receiptOcrExtension: Extension = { + id: 'receipt-ocr', name: 'Receipt OCR', version: '1.0.0', sector: 'general', + apiRoutes: receiptOcrApiRoutes, + eventHandlers: [ + { eventType: 'document.uploaded', handler: handleDocumentUploaded }, + { eventType: 'transaction.synced', handler: handleTransactionSynced }, + ], + mappingRuleTypes: [{ id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', description: '...' }], + settingsPanel: { label: 'Receipt OCR', path: '/settings/extensions/receipt-ocr' }, + async onInstall(ctx) { await ctx.settings.set('settings', DEFAULT_SETTINGS) }, +} +``` diff --git a/.claude/skills/create-extension/references/manifest-format.md b/.claude/skills/create-extension/references/manifest-format.md new file mode 100644 index 00000000..cafadb01 --- /dev/null +++ b/.claude/skills/create-extension/references/manifest-format.md @@ -0,0 +1,62 @@ +# Manifest Format Reference + +Every extension has a `manifest.json` in `extensions///`. + +## Complete Schema + +```json +{ + "id": "my-extension", + "sector": "general", + "exportName": "myExtensionExtension", + "entryPoint": "@/extensions/general/my-extension", + "workspace": "@/components/extensions/general/MyExtensionWorkspace", + "requiredEnvVars": ["MY_API_KEY"], + "optionalEnvVars": [], + "npmDependencies": ["some-package"], + "definition": { + "name": "My Extension", + "category": "operations", + "icon": "Box", + "dataPattern": "core", + "description": "Short marketplace card text", + "longDescription": "Longer detail page text.", + "readsCoreTables": ["invoices", "transactions"], + "hasOwnData": true, + "quickAction": { "label": "Do Thing", "description": "Short desc", "icon": "Zap", "href": "/path" }, + "subscriptionNotice": "Requires external subscription to X" + } +} +``` + +## Top-Level Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Kebab-case ID, must match directory name | +| `sector` | string | `"general"` (future: `"restaurant"`, `"construction"`, etc.) | +| `exportName` | string | camelCase export name: `toCamelCase(id) + "Extension"` | +| `entryPoint` | string | Path alias: `"@/extensions/{sector}/{id}"` | +| `workspace` | string \| null | Workspace component path, or `null` | +| `requiredEnvVars` | string[] | Required env vars (empty `[]` if none) | +| `optionalEnvVars` | string[] | Optional env vars (empty `[]` if none) | +| `npmDependencies` | string[] | Package deps for documentation (empty `[]` if none) | + +## Definition Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Display name | +| `category` | Yes | `"import"` / `"operations"` / `"reports"` / `"accounting"` | +| `icon` | Yes | Lucide icon name (e.g., `"Camera"`, `"Sparkles"`, `"Bell"`) — falls back to `"Puzzle"` | +| `dataPattern` | Yes | `"core"` (reads core tables) / `"manual"` (own data) / `"both"` | +| `description` | Yes | Short marketplace card text | +| `longDescription` | Yes | Longer detail page text | +| `readsCoreTables` | No | Which core tables it reads | +| `hasOwnData` | No | Whether it stores extension-specific data | +| `quickAction` | No | Dashboard quick action: `{ label, description, icon, href?, event?, order? }` | +| `subscriptionNotice` | No | Warning shown when enabling | + +## Naming Convention + +`my-extension` → `myExtension` → `myExtensionExtension` (export) → `@/extensions/general/my-extension` (entry point) diff --git a/.claude/skills/create-extension/references/services-patterns.md b/.claude/skills/create-extension/references/services-patterns.md new file mode 100644 index 00000000..f3d6fb2a --- /dev/null +++ b/.claude/skills/create-extension/references/services-patterns.md @@ -0,0 +1,63 @@ +# Service Integration Patterns + +Two patterns for extensions to provide services to core. Both ensure core compiles without the extension. + +## Pattern A: Interface Registration + +Best for single-implementation services (e.g., email). Core defines interface + noop default; extension registers at load time. + +```typescript +// Core: lib/email/service.ts +let emailService: EmailService = new NoopEmailService() +export function getEmailService(): EmailService { return emailService } +export function registerEmailService(svc: EmailService): void { emailService = svc } + +// Extension: extensions/general/email/index.ts +import { registerEmailService } from '@/lib/email/service' +registerEmailService(new ResendEmailService()) // Registers at module load +export const emailExtension: Extension = { id: 'email', name: 'Email', version: '1.0.0' } + +// Core consumption: +const svc = getEmailService() +if (svc.isConfigured()) await svc.sendEmail({ to, subject, html }) +``` + +## Pattern B: Services Record + +Best for multiple named functions (e.g., AI categorization). Extension exposes via `services`; core discovers via registry. + +```typescript +// Extension: +services: { + findSimilarTemplates: async (...args: unknown[]) => { + const { findSimilarTemplates } = await import('./lib/template-embeddings') + return findSimilarTemplates(args[0] as Transaction, args[1] as EntityType) + }, +}, + +// Core facade (lib/bookkeeping/template-embeddings.ts): +const aiExt = extensionRegistry.get('ai-categorization') +if (aiExt?.services?.findSimilarTemplates) { + return aiExt.services.findSimilarTemplates(transaction, entityType) +} +return findMatchingTemplates(transaction, entityType) // Fallback +``` + +## Pattern C: Core Services for Extensions + +Extensions consume core services via `ctx.services`: + +```typescript +await ctx?.services.ingestTransactions(ctx.supabase, ctx.userId, rawTransactions) +``` + +## Comparison + +| Aspect | Interface Registration | Services Record | +|--------|----------------------|-----------------| +| Defined in | Core (`lib/`) | Extension (`index.ts`) | +| Discovery | `get*()` getter | `extensionRegistry.get().services` | +| Functions | Single interface | Multiple named | +| Lazy imports | No | Yes (inside service fns) | +| Fallback | Noop default | Core provides fallback | +| Example | Email | AI categorization | diff --git a/.claude/skills/create-extension/references/testing.md b/.claude/skills/create-extension/references/testing.md new file mode 100644 index 00000000..a4c30e05 --- /dev/null +++ b/.claude/skills/create-extension/references/testing.md @@ -0,0 +1,66 @@ +# Testing Extensions + +Vitest, same patterns as core. Tests colocated in `__tests__/` next to extension files. + +## Setup + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' + +vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +beforeEach(() => { vi.clearAllMocks(); eventBus.clear() }) +``` + +## Mock Context + +```typescript +const mockCtx: ExtensionContext = { + userId: 'user-123', extensionId: 'my-extension', + supabase: createMockSupabase() as any, + emit: vi.fn(), + settings: { get: vi.fn().mockResolvedValue({ featureEnabled: true }), set: vi.fn() }, + storage: { download: vi.fn(), upload: vi.fn(), getPublicUrl: vi.fn() }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + services: { ingestTransactions: vi.fn() }, +} +``` + +## Testing Event Handlers + +```typescript +it('should process events', async () => { + const handler = myExt.eventHandlers!.find(h => h.eventType === 'transaction.synced')!.handler + await handler({ transactions: [makeTransaction()], userId: 'user-123' }, mockCtx) + expect(mockCtx.supabase.from).toHaveBeenCalled() +}) + +it('should skip when disabled', async () => { + mockCtx.settings.get = vi.fn().mockResolvedValue({ featureEnabled: false }) + const handler = myExt.eventHandlers!.find(h => h.eventType === 'transaction.synced')!.handler + await handler({ transactions: [], userId: 'user-123' }, mockCtx) + expect(mockCtx.supabase.from).not.toHaveBeenCalled() +}) +``` + +## Testing API Routes + +```typescript +const handler = myExtApiRoutes.find(r => r.method === 'GET' && r.path === '/')!.handler +const request = createMockRequest('GET', '/api/extensions/ext/my-extension/') +const response = await handler(request, mockCtx) +expect(response.status).toBe(200) +``` + +## Test Helpers (`tests/helpers.ts`) + +`createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest(method, url, body?)`, `parseJsonResponse(response)`, `makeTransaction()`, `makeJournalEntry()`, `makeInvoice()`, `makeCustomer()`, `makeSupplier()`, `makeReceipt()`, `makeDocumentAttachment()`, `makeCompanySettings()` + +## Running + +```bash +npm test # All tests +npx vitest run extensions/general/my-extension # Specific extension +``` diff --git a/.claude/skills/create-extension/references/workspace-ui.md b/.claude/skills/create-extension/references/workspace-ui.md new file mode 100644 index 00000000..01dc5ace --- /dev/null +++ b/.claude/skills/create-extension/references/workspace-ui.md @@ -0,0 +1,59 @@ +# Workspace & UI Reference + +## Workspace Components + +Each extension can have a workspace page at `/e/{sector}/{slug}`. + +**1. Create component:** +```typescript +// components/extensions/general/MyExtensionWorkspace.tsx +'use client' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' + +export default function MyExtensionWorkspace({ userId }: WorkspaceComponentProps) { + return
{/* Extension UI */}
+} +``` + +**2. Set in manifest:** `"workspace": "@/components/extensions/general/MyExtensionWorkspace"` + +**3. Regenerate:** `npm run setup:extensions` + +Render chain: `/e/general/my-extension` → auth+toggle check → `ExtensionWorkspaceShell` (breadcrumb+header) → your component. + +Set `"workspace": null` for extensions without a dedicated page (event-only, service-only, etc.). + +## Settings Panels + +Declare in extension object: +```typescript +settingsPanel: { label: 'My Extension', path: '/settings/extensions/my-extension' }, +``` + +Register panel in `lib/extensions/settings-panel-registry.tsx`: +```typescript +case 'my-extension': + return dynamic(() => import('@/components/extensions/general/my-extension/MyExtSettings')) +``` + +Settings component fetches/saves via extension API routes (`GET/PUT /settings`). + +## Sidebar + +Enabled extensions auto-appear in the "Tillägg" sidebar section as links to `/e/{sector}/{slug}`. Additional nav items via: +```typescript +sidebarItems: [{ label: 'My Tool', icon: 'Wrench', path: '/tools/my-tool', order: 10 }] +``` + +## Toggle System + +Toggle state stored in `extension_toggles` table `(user_id, sector_slug, extension_slug, enabled)`. Checked server-side via `isExtensionEnabled()`. Toggle changes dispatch `extension-toggle-changed` custom event. + +## Hooks + +```typescript +const { extensions, isLoading, refresh } = useEnabledExtensions() // lib/extensions/hooks +const { enabled, isLoading, toggle } = useExtensionToggle('general', 'my-extension') +const { data, save, remove, getByKey } = useExtensionData('general', 'my-extension') +const { totals, monthly, totalNet } = useAccountTotals({ from, to, monthly: true }) +```