Document: docs/developer/creating-apps.md

---
title: Creating Apps
sidebar_position: 1
---

# Creating Apps

A guide to building Framework M applications.

## Prerequisites

- **Python 3.12+**
- **uv** package manager

## Installation

To build applications, you need the **Framework M Studio** package which provides development tooling and scaffolding. This should be installed as a development dependency:

```bash
# Add framework
uv add framework-m

# Add dev tools
uv add --dev framework-m-studio
```

## Create a New App

The `m` CLI provides scaffolding commands to create new apps.

```bash
# From your project root
uv run m new:app my_crm
```

This creates:
```
my_crm/
├── src/
│   └── my_crm/           # Namespaced package
│       ├── __init__.py
│       └── doctypes/
│           └── __init__.py
├── pyproject.toml
└── README.md
```

## Add a DocType

```bash
cd my_crm
uv sync
uv run m new:doctype Contact
```

This creates:
```
src/my_crm/doctypes/contact/
├── __init__.py
├── doctype.py      # Schema definition
└── controller.py   # Business logic

tests/doctypes/contact/
└── test_contact.py # Tests
```

## Define Fields

Edit `src/my_crm/doctypes/contact/doctype.py`:

```python
from __future__ import annotations

from typing import ClassVar
from pydantic import Field
from framework_m.core.domain.base_doctype import BaseDocType

class Contact(BaseDocType):
    """Customer contact information."""

    __doctype_name__: ClassVar[str] = "Contact"

    # Required fields
    first_name: str = Field(description="First name")
    last_name: str = Field(description="Last name")

    # Optional fields
    email: str = Field(default="", description="Email")
    phone: str = Field(default="", description="Phone")
    company: str = Field(default="", description="Company name")

    class Meta:
        naming_rule: ClassVar[str] = "autoincrement"
```

## Add Business Logic

Edit `src/doctypes/contact/controller.py`:

```python
from __future__ import annotations

from framework_m.core.domain.base_controller import BaseController
from .doctype import Contact

class ContactController(BaseController[Contact]):
    """Contact business logic."""

    async def validate(self, context=None):
        """Validate before saving."""
        if not self.doc.first_name.strip():
            raise ValueError("First name is required")

        # Normalize email
        if self.doc.email:
            self.doc.email = self.doc.email.lower().strip()

    async def after_save(self, context=None):
        """Called after save."""
        # Example: Log or send notification
        pass
```

## Run Migrations

```bash
# Initialize Alembic (first time)
uv run m migrate init

# Create migration for your DocType
uv run m migrate create "Add Contact doctype" --autogenerate

# Apply migration
uv run m migrate
```

## Start Studio

```bash
uv run m studio
```

Open http://localhost:9000 to manage your data.

## Test Your DocType

Edit `src/doctypes/contact/test_contact.py`:

```python
import pytest
from .doctype import Contact
from .controller import ContactController

def test_contact_creation():
    contact = Contact(
        first_name="John",
        last_name="Doe",
        email="John@Example.com",
    )
    assert contact.first_name == "John"

@pytest.mark.asyncio
async def test_validate_normalizes_email():
    contact = Contact(
        first_name="John",
        last_name="Doe",
        email="John@Example.com",
    )
    controller = ContactController(contact)
    await controller.validate()

    assert contact.email == "john@example.com"

@pytest.mark.asyncio
async def test_validate_rejects_empty_name():
    contact = Contact(
        first_name="   ",
        last_name="Doe",
    )
    controller = ContactController(contact)

    with pytest.raises(ValueError, match="First name is required"):
        await controller.validate()
```

Run tests:
```bash
uv run pytest src/doctypes/contact/
```

## Multiple DocTypes

For related DocTypes, create a directory structure:

```
src/my_crm/doctypes/
├── __init__.py
├── contact/
│   ├── doctype.py
│   └── controller.py
├── lead/
│   ├── doctype.py
│   └── controller.py
└── opportunity/
    ├── doctype.py
    └── controller.py
```

## Next Steps

- [Defining DocTypes](./defining-doctypes.md) - Advanced patterns
- [Architecture](./architecture.md) - System overview


Document: docs/developer/locale-resolution.md

---
title: Locale Resolution
sidebar_position: 4
---

# Locale Resolution and Field Label Translation
and how field labels are automatically translated in the Meta API.

## Locale Resolution Order

The system resolves the user's locale in the following order:

1. **Accept-Language header** - Browser language preference
2. **User preference** - user.locale field in LocalUser/UserPreferences
3. **Tenant default** - tenant.attributes.default_locale
4. **System default** - DEFAULT_LOCALE setting (defaults to "en")

## Usage Examples

### 1. Frontend: Setting Locale via Accept-Language Header

```typescript
// Frontend automatically sends Accept-Language header
fetch("http://localhost:8000/api/meta/Invoice", {
  headers: {
    "Accept-Language": "hi-IN,hi;q=0.9,en;q=0.8",
  },
});

// The middleware parses this and resolves locale to 'hi'
```

### 2. Backend: User Preference

```python
# Set user's locale preference
user = LocalUser(
    email="john@example.com",
    password_hash="...",
    locale="hi"  # Hindi preference
)

# When this user makes requests, locale="hi" takes precedence
# over Accept-Language header
```

### 3. Backend: Tenant Default Locale

```python
# Configure tenant with default locale
tenant = TenantContext(
    tenant_id="acme-corp",
    attributes={
        "default_locale": "ta",  # Tamil
        "plan": "enterprise"
    }
)

# All users in this tenant default to Tamil
# unless they have a personal preference
```

### 4. Backend: Accessing Resolved Locale

```python
from litestar import get, Request
from framework_m.adapters.web.middleware.locale import provide_locale

@get("/api/greeting")
async def get_greeting(locale: str = provide_locale) -> str:
    \"\"\"Greet user in their preferred language.\"\"\"
    greetings = {
        "en": "Hello!",
        "hi": "नमस्ते!",
        "ta": "வணக்கம்!",
    }
    return greetings.get(locale, greetings["en"])
```

## Field Label Translation

Field labels (Field.description and Field.title) are automatically
translated in the Meta API response based on the resolved locale.

### Example: Creating Translations

```python
# 1. Create Translation entries
from framework_m.core.doctypes.translation import Translation

# English (original)
Translation(
    source_text="Customer Name",
    translated_text="Customer Name",
    locale="en",
    context="field_label"
)

# Hindi translation
Translation(
    source_text="Customer Name",
    translated_text="ग्राहक का नाम",
    locale="hi",
    context="field_label"
)

# Tamil translation
Translation(
    source_text="Customer Name",
    translated_text="வாடிக்கையாளர் பெயர்",
    locale="ta",
    context="field_label"
)
```

### Example: DocType with Translatable Fields

```python
from pydantic import Field
from framework_m.core.domain.base_doctype import BaseDocType

class Invoice(BaseDocType):
    \"\"\"Invoice DocType with translatable field labels.\"\"\"

    customer: str = Field(
        description="Customer Name",  # Will be translated
        min_length=1,
        max_length=200
    )

    total: float = Field(
        description="Total Amount",  # Will be translated
        ge=0
    )
```

### Example: Meta API Response (Hindi locale)

```bash
# Request with Hindi locale
curl -H "Accept-Language: hi" http://localhost:8000/api/meta/Invoice

# Response (field labels translated)
{
  "doctype": "Invoice",
  "locale": "hi",
  "schema": {
    "properties": {
      "customer": {
        "type": "string",
        "description": "ग्राहक का नाम",  # Translated!
        "minLength": 1,
        "maxLength": 200
      },
      "total": {
        "type": "number",
        "description": "कुल राशि",  # Translated!
        "minimum": 0
      }
    }
  }
}
```

## Middleware Configuration

Add the locale resolution middleware to your Litestar app:

```python
from litestar import Litestar
from framework_m.adapters.web.middleware.locale import create_locale_middleware

app = Litestar(
    route_handlers=[...],
    middleware=[
        create_locale_middleware(),
        # ... other middleware
    ]
)
```

## Translation Context

The `context` field in Translation DocType allows disambiguation:

```python
# Button label
Translation(
    source_text="Save",
    translated_text="सहेजें",
    locale="hi",
    context="button"  # Specific to buttons
)

# Field label
Translation(
    source_text="Save",
    translated_text="बचत",
    locale="hi",
    context="field_label"  # Different meaning for field labels
)
```

## Benefits

1. **Automatic**: No manual translation code needed in endpoints
2. **Flexible**: Multiple fallback levels ensure graceful degradation
3. **User-friendly**: Respects browser language, user preference, and tenant defaults
4. **Consistent**: Same locale resolution logic across all endpoints
5. **Cacheable**: DefaultI18nAdapter caches translations in memory

## See Also

- `framework_m.adapters.web.middleware.locale` - Middleware implementation
- `framework_m.adapters.i18n.DefaultI18nAdapter` - Translation adapter
- `framework_m.core.doctypes.translation` - Translation DocType
- `framework_m.core.doctypes.user` - User.locale field
- `framework_m.core.interfaces.tenant` - TenantContext.attributes.default_locale


Document: docs/developer/defining-doctypes.md

---
title: Defining DocTypes
sidebar_position: 2
---

# Defining DocTypes

Advanced patterns for defining DocTypes in Framework M.

## Introduction to DocTypes

In Framework M, a **DocType** is both a data schema (Pydantic model) and a database table definition. All DocTypes should inherit from `DocType` (an alias for `BaseDocType`).

```python
from framework_m import DocType, Field

class MyDocType(DocType):
    """DocType definition."""
    # fields...
```

## Field Patterns

### Required vs Optional

```python
# Required - no default
name: str = Field(description="Customer name")

# Optional - with default
email: str = Field(default="", description="Email")

# Nullable optional
phone: str | None = Field(default=None, description="Phone")
```

### Unique Fields

To enforce a unique constraint at the database level:

```python
# Email must be unique
email: str = Field(json_schema_extra={"unique": True})

# Code must be unique
code: str = Field(min_length=3, json_schema_extra={"unique": True})
```

### Field Validation

```python
from framework_m import Field

# String constraints
code: str = Field(min_length=3, max_length=20)

# Number constraints
quantity: int = Field(ge=0, le=1000)  # >= 0, <= 1000
price: Decimal = Field(gt=0)          # > 0

# Pattern matching
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
```

### Common Types

```python
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID

# Dates
due_date: date = Field(description="Due date")
created_at: datetime = Field(description="Created timestamp")

# Money
amount: Decimal = Field(decimal_places=2, description="Amount")

# References
related_id: UUID = Field(description="Related document ID")
```

## Child Tables

For one-to-many relationships:

```python
class InvoiceItem(DocType):
    """Invoice line item."""

    __doctype_name__: ClassVar[str] = "InvoiceItem"

    item_code: str = Field(description="Item code")
    quantity: int = Field(default=1, ge=1)
    rate: Decimal = Field(description="Unit price")
    amount: Decimal = Field(default=Decimal("0"))

    class Meta:
        is_child_table: ClassVar[bool] = True

class Invoice(DocType):
    """Invoice with line items."""

    __doctype_name__: ClassVar[str] = "Invoice"

    customer: str = Field(description="Customer name")
    items: list[InvoiceItem] = Field(default_factory=list)
    total: Decimal = Field(default=Decimal("0"))
```

Calculate totals in controller:

```python
from framework_m import Controller

class InvoiceController(Controller[Invoice]):
    async def before_save(self, context=None):
        for item in self.doc.items:
            item.amount = item.quantity * item.rate
        self.doc.total = sum(i.amount for i in self.doc.items)
```

## Meta Options

```python
class Invoice(DocType):
    # ... fields ...

    class Meta:
        # Naming
        naming_rule: ClassVar[str] = "autoincrement"
        # Options: "autoincrement", "uuid", "field:customer"

        # Behavior
        is_submittable: ClassVar[bool] = True
        is_child_table: ClassVar[bool] = False

        # API exposure
        api_resource: ClassVar[bool] = True

        # Database
        table_name: ClassVar[str] = "invoice"  # Custom table name

        # Permissions
        permissions: ClassVar[dict] = {
            "read": ["Employee", "Manager"],
            "write": ["Manager"],
            "submit": ["Manager"],
            "delete": ["Administrator"],
        }
```

## Submittable Documents

For documents with approval workflow:

```python
class PurchaseOrder(DocType):
     __doctype_name__: ClassVar[str] = "PurchaseOrder"

    supplier: str = Field(description="Supplier")
    total: Decimal = Field(description="Total amount")
    docstatus: int = Field(default=0)  # 0=Draft, 1=Submitted, 2=Cancelled

    class Meta:
        is_submittable: ClassVar[bool] = True
```

Controller hooks for submission:

```python
from framework_m import Controller

class PurchaseOrderController(Controller[PurchaseOrder]):
    async def on_submit(self, context=None):
        """Called when document is submitted."""
        # Create ledger entries, reserve inventory, etc.
        if self.doc.total > 10000:
            raise ValueError("Orders over 10000 require approval")

    async def on_cancel(self, context=None):
        """Called when document is cancelled."""
        # Reverse ledger entries, release inventory, etc.
        pass
```

## Custom Validators

For complex validation:

```python
from pydantic import field_validator, model_validator

class Order(DocType):
    start_date: date
    end_date: date
    quantity: int

    @field_validator("quantity")
    @classmethod
    def validate_quantity(cls, v):
        if v <= 0:
            raise ValueError("Quantity must be positive")
        return v

    @model_validator(mode="after")
    def validate_dates(self):
        if self.end_date < self.start_date:
            raise ValueError("End date must be after start date")
        return self
```

## Database Indexes

For query performance:

```python
class Meta:
    indexes: ClassVar[list] = [
        ("status",),                    # Single column
        ("customer", "date"),           # Composite
        ("owner", "creation"),          # For RLS queries
    ]
```

## Linking DocTypes

Reference other DocTypes:

```python
class Invoice(DocType):
    # Simple reference (stores ID)
    customer_id: UUID = Field(description="Customer")

    # Reference with name (for display)
    customer_name: str = Field(default="", description="Customer name")
```

In controller, fetch related data:

```python
async def before_save(self, context=None):
    if self.doc.customer_id:
        customer = await customer_repo.get(self.doc.customer_id)
        if customer:
            self.doc.customer_name = customer.name
```

## Next Steps

- [Creating Apps](./creating-apps.md) - Build complete applications
- [Architecture](./architecture.md) - System overview


Document: docs/developer/migrations.md

# Database Migrations

Framework M uses **Alembic** to manage database migrations. It supports both automatic discovery of DocTypes and selective migration based on "installed apps".

## Migration Anatomy
Migrations are stored in the host application's directory (e.g., `apps/business-m/alembic/`).

- `alembic/env.py`: Orchestrates how DocTypes are discovered and how MetaData is built.
- `alembic/versions/`: Contains the generated migration scripts.
- `alembic.ini`: Configuration file for Alembic.

## Discovery Strategies

Framework M supports two modes of DocType discovery for migrations:

### 1. Selective Discovery (Recommended for Production)
This mode mimics Frappe's behavior. Migrations ONLY manage DocTypes from apps specified in the `INSTALLED_APPS` list.

**How to trigger:**
- Use the `--apps` flag in the CLI:
  ```bash
  m migrate create "added finance" --apps "finance,m_imprest"
  ```
- Or set the `INSTALLED_APPS` environment variable:
  ```bash
  export INSTALLED_APPS="finance,wms"
  m migrate status
  ```

**How it works:**
The `env.py` script calls `MetaRegistry.load_apps(apps)`, which imports the specified packages and registers their DocTypes. Tables not in these apps are ignored by the migration generator.

### 2. Monolithic Discovery (Development Default)
If no `INSTALLED_APPS` or `--apps` are provided, the system falls back to a recursive filesystem scan of the `src/` folder.

**How it works:**
The system crawls all directories named `doctypes` within the workspace and automatically registers every DocType it finds.

## Sync vs. Migrate (The Dual-Track Approach)

To provide a Frappe-like "it just works" experience while maintaining production-grade control, Framework M uses a dual-track strategy:

### 1. The "Sync" Track (Declarative / Frappe-style)
Use `m migrate sync` when you want the database to exactly match your current DocType definitions.

- **Behavior**: Compares current DocTypes with the DB schema and applies `CREATE TABLE` / `ALTER TABLE` immediately.
- **Self-Contained**: No migration files are created. It is entirely driven by code.
- **Use Case**: Rapid development, local prototyping, and "Syncing" schema stability.

### 1.1 Sync Hooks (The Lifecycle Track)
To handle complex data transitions during a `sync`, each app can define hooks in its `migrations` module:

**File: `libs/wms/src/wms/migrations.py`**
```python
def before_sync(engine):
    """Run before DDL sync (e.g. drop old constraints)"""
    pass

def after_sync(engine):
    """Run after DDL sync (e.g. initialize default data)"""
    pass
```

- **Execution**: `m migrate sync` automatically discovers these hooks in all apps listed in `--apps`.
- **Context**: The hooks receive a SQLAlchemy `engine` for direct data manipulation.

### 2. The "Migrate" Track (Imperative / Alembic)
Use the standard commands (`create`, `run`) when you need to ship **ordered patches** or logic-heavy transitions (data migrations, complex index changes).

- **Behavior**: Uses version files inside each app's `alembic/versions` folder.
- **Self-Contained**: Each app maintains its own version history.
- **Use Case**: Production rollouts, data migrations, and multi-tenant patches.

```bash
m migrate create "v1 patch" --app "wms"
m migrate
```

## Comparisons with Frappe

| Concept | Frappe | Framework M |
|---------|--------|-------------|
| **Auto-Sync** | `bench migrate` (sync phase) | `m migrate sync` |
| **Manual Patches** | `patches.txt` + Python scripts | `m migrate run` + Alembic scripts |
| **Modularity** | Distributed `patches.txt` in each app | Distributed `alembic/versions` in each app |
| **Dependencies** | Handled via `after_install` or global order | Handled via Alembic's `depends_on` |

## Commands

| Command | Description |
|---------|-------------|
| `m migrate all` | **(Recommended)** Runs both versioned patches (`run`) and declarative sync. |
| `m migrate sync` | Direct DocType -> DB synchronization (no files). |
| `m migrate init` | Initialize Alembic for versioned migrations. |
| `m migrate create "msg" [--app ...]` | Generate a versioned patch for a specific app. |
| `m migrate` | Alias for `m migrate run`. Runs all pending versioned patches. |
| `m migrate status` | Show current database revision and pending status. |

## Zero-Downtime Deployment Strategy (DevOps)

By decoupling `sync` and `run`, Framework M enables advanced zero-downtime workflows:

1.  **Stage 1: Safe Sync** - Run `m migrate sync` (declarative) before updating application code. This adds new nullable columns or tables that don't break existing code.
2.  **Stage 2: Code Rollout** - Update application instances to the new version.
3.  **Stage 3: Data Patches** - Run `m migrate run` (imperative) to perform data transformations or breaking schema changes once the new code is fully operational.

## Tips for Monorepos
In a monorepo, it is best to run migrations from the **main app** (`apps/business-m`) but specify which **libs** should be included in the schema using the `--apps` flag.


Document: docs/developer/plugin-menu-code-examples.md

# Plugin Menu Implementation - Code Examples

> **Prerequisites:** Read [Plugin Architecture Implementation](./plugin-architecture-implementation.md) first

## Complete Working Example

This document shows the **exact code** you'll write to implement plugin menus.

---

## Phase 1: Build @framework-m/plugin-sdk Package

### Directory Structure

```
libs/framework-m-plugin-sdk/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
    ├── index.ts                    # Public API exports
    ├── types/
    │   └── plugin.ts               # TypeScript interfaces
    ├── core/
    │   ├── PluginRegistry.ts       # Central registry
    │   └── ServiceContainer.ts     # DI container
    ├── hooks/
    │   ├── usePluginMenu.ts        # React hook for menus
    │   ├── usePlugin.ts            # React hook for plugin data
    │   └── useService.ts           # React hook for services
    └── context/
        └── PluginRegistryContext.tsx  # React context provider
```

### 1. package.json

```json
{
  "name": "@framework-m/plugin-sdk",
  "version": "0.1.0",
  "description": "Plugin system SDK for Framework M multi-app projects",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist", "README.md"],
  "scripts": {
    "build": "tsc && vite build",
    "dev": "vite build --watch",
    "type-check": "tsc --noEmit"
  },
  "keywords": ["framework-m", "plugin", "sdk", "multi-app"],
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.1.0",
    "@types/react-dom": "^19.1.0",
    "typescript": "^5.8.0",
    "vite": "^6.3.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  }
}
```

### 2. src/types/plugin.ts

```typescript
import { RouteObject } from "react-router-dom";

/**
 * Menu item representation
 */
export interface MenuItem {
  /** Unique identifier (e.g., "sales.invoice") */
  name: string;

  /** Display label (e.g., "Sales Invoice") */
  label: string;

  /** Route path (e.g., "/doctypes/sales.invoice") */
  route: string;

  /** Icon name from lucide-react (e.g., "file-text") */
  icon?: string;

  /** Module grouping (e.g., "Sales", "Inventory") */
  module?: string;

  /** Category within module (e.g., "Transactions", "Masters") */
  category?: string;

  /** Sort order (lower = higher priority) */
  order?: number;

  /** Nested menu items */
  children?: MenuItem[];

  /** Badge count (e.g., notification count) */
  badge?: number;

  /** Whether item is visible */
  hidden?: boolean;
}

/**
 * Service factory function
 */
export type ServiceFactory<T = any> = () => Promise<T> | T;

/**
 * Provider component
 */
export interface Provider {
  /** Component factory (lazy loaded) */
  component: () => Promise<{ default: React.ComponentType<any> }>;

  /** Props to pass to provider */
  props?: Record<string, any>;
}

/**
 * DocType extension
 */
export interface DocTypeExtension {
  /** DocType name to extend */
  doctype: string;

  /** Custom list component */
  listComponent?: () => Promise<{ default: React.ComponentType<any> }>;

  /** Custom form component */
  formComponent?: () => Promise<{ default: React.ComponentType<any> }>;

  /** Custom show component */
  showComponent?: () => Promise<{ default: React.ComponentType<any> }>;

  /** Additional fields */
  fields?: Array<{
    name: string;
    label: string;
    type: string;
  }>;
}

/**
 * Dashboard widget
 */
export interface Widget {
  /** Widget ID */
  id: string;

  /** Widget title */
  title: string;

  /** Component factory */
  component: () => Promise<{ default: React.ComponentType<any> }>;

  /** Grid position */
  position?: {
    x: number;
    y: number;
    w: number;
    h: number;
  };
}

/**
 * Plugin manifest
 */
export interface FrameworkMPlugin {
  /** Plugin name (unique identifier) */
  name: string;

  /** Semantic version */
  version: string;

  /** Human-readable description */
  description?: string;

  /** Menu items contributed by this plugin */
  menu?: MenuItem[];

  /** Routes contributed by this plugin */
  routes?: RouteObject[];

  /** Services provided by this plugin */
  services?: Record<string, ServiceFactory>;

  /** Provider components */
  providers?: Provider[];

  /** DocType extensions */
  doctypes?: Record<string, DocTypeExtension>;

  /** Dashboard widgets */
  widgets?: Widget[];

  /** Plugin dependencies */
  dependencies?: string[];

  /** Plugin initialization function */
  onInit?: () => Promise<void> | void;

  /** Plugin cleanup function */
  onDestroy?: () => Promise<void> | void;
}
```

### 3. src/core/PluginRegistry.ts

```typescript
import { FrameworkMPlugin, MenuItem } from "../types/plugin";
import { RouteObject } from "react-router-dom";

/**
 * Central registry for all plugins
 */
export class PluginRegistry {
  private plugins = new Map<string, FrameworkMPlugin>();
  private menuCache: MenuItem[] | null = null;
  private routesCache: RouteObject[] | null = null;

  /**
   * Register a plugin
   */
  async register(plugin: FrameworkMPlugin): Promise<void> {
    // Validation
    if (!plugin.name) {
      throw new Error("Plugin must have a name");
    }

    if (!plugin.version) {
      throw new Error("Plugin must have a version");
    }

    // Check dependencies
    if (plugin.dependencies) {
      for (const dep of plugin.dependencies) {
        if (!this.plugins.has(dep)) {
          console.warn(
            `Plugin ${plugin.name} depends on ${dep} which is not registered`,
          );
        }
      }
    }

    // Register
    if (this.plugins.has(plugin.name)) {
      console.warn(`Plugin ${plugin.name} already registered, overwriting`);
    }

    this.plugins.set(plugin.name, plugin);

    // Invalidate caches
    this.menuCache = null;
    this.routesCache = null;

    // Call onInit if provided
    if (plugin.onInit) {
      await plugin.onInit();
    }

    console.log(`✓ Registered plugin: ${plugin.name}@${plugin.version}`);
  }

  /**
   * Get merged menu tree from all plugins
   */
  getMenu(): MenuItem[] {
    if (this.menuCache) {
      return this.menuCache;
    }

    const allMenuItems: MenuItem[] = [];

    // Collect all menu items
    for (const plugin of this.plugins.values()) {
      if (plugin.menu) {
        allMenuItems.push(...plugin.menu);
      }
    }

    // Build hierarchical menu
    this.menuCache = this.buildMenuTree(allMenuItems);
    return this.menuCache;
  }

  /**
   * Build hierarchical menu tree grouped by module and category
   */
  private buildMenuTree(items: MenuItem[]): MenuItem[] {
    const moduleMap = new Map<string, MenuItem>();

    for (const item of items) {
      // Skip hidden items
      if (item.hidden) continue;

      const moduleName = item.module || "Other";
      const moduleKey = moduleName.toLowerCase();

      // Get or create module group
      if (!moduleMap.has(moduleKey)) {
        moduleMap.set(moduleKey, {
          name: moduleKey,
          label: moduleName,
          route: `/${moduleKey}`,
          icon: this.getModuleIcon(moduleName),
          order: item.order,
          children: [],
        });
      }

      const moduleGroup = moduleMap.get(moduleKey)!;

      if (item.category) {
        // Find or create category subgroup
        const categoryKey = item.category.toLowerCase();
        let categoryGroup = moduleGroup.children?.find(
          c => c.name === categoryKey,
        );

        if (!categoryGroup) {
          categoryGroup = {
            name: categoryKey,
            label: item.category,
            route: `/${moduleKey}/${categoryKey}`,
            children: [],
          };
          moduleGroup.children!.push(categoryGroup);
        }

        // Add item to category
        categoryGroup.children!.push(item);
      } else {
        // No category, add directly to module
        moduleGroup.children!.push(item);
      }
    }

    // Convert to array and sort
    const modules = Array.from(moduleMap.values());

    // Sort modules by order
    modules.sort((a, b) => (a.order || 999) - (b.order || 999));

    // Sort children within each module
    for (const module of modules) {
      if (module.children) {
        // Sort categories
        module.children.sort((a, b) => (a.order || 999) - (b.order || 999));

        // Sort items within categories
        for (const category of module.children) {
          if (category.children) {
            category.children.sort(
              (a, b) => (a.order || 999) - (b.order || 999),
            );
          }
        }
      }
    }

    return modules;
  }

  /**
   * Get icon for module
   */
  private getModuleIcon(moduleName: string): string {
    const icons: Record<string, string> = {
      Sales: "shopping-cart",
      Inventory: "package",
      HR: "users",
      Personnel: "users",
      Finance: "dollar-sign",
      Accounting: "calculator",
      Core: "settings",
      Settings: "settings",
      Warehouse: "warehouse",
      WMS: "warehouse",
    };

    return icons[moduleName] || "folder";
  }

  /**
   * Get all routes from all plugins
   */
  getRoutes(): RouteObject[] {
    if (this.routesCache) {
      return this.routesCache;
    }

    const allRoutes: RouteObject[] = [];

    for (const plugin of this.plugins.values()) {
      if (plugin.routes) {
        allRoutes.push(...plugin.routes);
      }
    }

    this.routesCache = allRoutes;
    return allRoutes;
  }

  /**
   * Get specific plugin by name
   */
  getPlugin(name: string): FrameworkMPlugin | undefined {
    return this.plugins.get(name);
  }

  /**
   * Get all registered plugins
   */
  getAllPlugins(): FrameworkMPlugin[] {
    return Array.from(this.plugins.values());
  }

  /**
   * Unregister a plugin
   */
  async unregister(name: string): Promise<void> {
    const plugin = this.plugins.get(name);

    if (!plugin) {
      console.warn(`Plugin ${name} not found`);
      return;
    }

    // Call onDestroy if provided
    if (plugin.onDestroy) {
      await plugin.onDestroy();
    }

    this.plugins.delete(name);

    // Invalidate caches
    this.menuCache = null;
    this.routesCache = null;

    console.log(`✓ Unregistered plugin: ${name}`);
  }

  /**
   * Clear all plugins
   */
  async clear(): Promise<void> {
    const plugins = Array.from(this.plugins.values());

    for (const plugin of plugins) {
      await this.unregister(plugin.name);
    }
  }
}
```

### 4. src/context/PluginRegistryContext.tsx

```typescript
import { createContext, ReactNode, useEffect, useState } from 'react';
import { PluginRegistry } from '../core/PluginRegistry';

export const PluginRegistryContext = createContext<PluginRegistry | null>(null);

interface PluginRegistryProviderProps {
  children: ReactNode;
  registry?: PluginRegistry;
}

export function PluginRegistryProvider({
  children,
  registry: externalRegistry,
}: PluginRegistryProviderProps) {
  const [registry] = useState(() => externalRegistry || new PluginRegistry());
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    setIsReady(true);
  }, []);

  if (!isReady) {
    return (
      <div style={{ padding: '2rem', textAlign: 'center' }}>
        Loading plugins...
      </div>
    );
  }

  return (
    <PluginRegistryContext.Provider value={registry}>
      {children}
    </PluginRegistryContext.Provider>
  );
}
```

### 5. src/hooks/usePluginMenu.ts

```typescript
import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { MenuItem } from "../types/plugin";

/**
 * Hook to access the merged menu tree from all plugins
 */
export function usePluginMenu(): MenuItem[] {
  const registry = useContext(PluginRegistryContext);

  if (!registry) {
    throw new Error("usePluginMenu must be used within PluginRegistryProvider");
  }

  return useMemo(() => registry.getMenu(), [registry]);
}
```

### 6. src/hooks/usePlugin.ts

```typescript
import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { FrameworkMPlugin } from "../types/plugin";

/**
 * Hook to access a specific plugin by name
 */
export function usePlugin(name: string): FrameworkMPlugin | undefined {
  const registry = useContext(PluginRegistryContext);

  if (!registry) {
    throw new Error("usePlugin must be used within PluginRegistryProvider");
  }

  return useMemo(() => registry.getPlugin(name), [registry, name]);
}
```

### 7. src/index.ts (Public API)

```typescript
// Types
export type {
  MenuItem,
  FrameworkMPlugin,
  ServiceFactory,
  Provider,
  DocTypeExtension,
  Widget,
} from "./types/plugin";

// Core
export { PluginRegistry } from "./core/PluginRegistry";

// Context
export {
  PluginRegistryContext,
  PluginRegistryProvider,
} from "./context/PluginRegistryContext";

// Hooks
export { usePluginMenu } from "./hooks/usePluginMenu";
export { usePlugin } from "./hooks/usePlugin";
```

---

## Phase 2: Update Shell App

### frontend/package.json

```json
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@framework-m/desk": "^0.1.0",
    "@framework-m/plugin-sdk": "^0.1.0",
    "@refinedev/core": "^5.0.8",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-router-dom": "^7.12.0"
  }
}
```

### frontend/src/App.tsx

```typescript
import { PluginRegistry, PluginRegistryProvider } from '@framework-m/plugin-sdk';
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './layout/Layout';

// For now, manually import plugins
// Later: @framework-m/vite-plugin will auto-generate this
import wmsPlugin from '../../apps/wms/frontend/dist/plugin.config';
import personnelPlugin from '../../apps/personnel/frontend/dist/plugin.config';

const plugins = [wmsPlugin, personnelPlugin];

async function bootstrap() {
  const registry = new PluginRegistry();

  // Register all plugins
  for (const plugin of plugins) {
    await registry.register(plugin);
  }

  console.log('✓ All plugins registered');
  return registry;
}

export function App() {
  const [registry, setRegistry] = useState<PluginRegistry | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    bootstrap()
      .then(setRegistry)
      .catch(setError);
  }, []);

  if (error) {
    return (
      <div style={{ padding: '2rem', color: 'red' }}>
        <h1>Plugin Error</h1>
        <pre>{error.message}</pre>
      </div>
    );
  }

  if (!registry) {
    return (
      <div style={{ padding: '2rem', textAlign: 'center' }}>
        Loading plugins...
      </div>
    );
  }

  return (
    <PluginRegistryProvider registry={registry}>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Layout />}>
            {/* Dynamic routes from plugins */}
            {registry.getRoutes().map((route, i) => (
              <Route key={i} {...route} />
            ))}
          </Route>
        </Routes>
      </BrowserRouter>
    </PluginRegistryProvider>
  );
}
```

### frontend/src/layout/Sidebar.tsx

```typescript
import { usePluginMenu } from '@framework-m/plugin-sdk';
import { useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Icon } from '../components/Icon';

export function Sidebar() {
  const navigate = useNavigate();
  const location = useLocation();
  const pluginMenu = usePluginMenu();
  const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});

  // Group menu by module
  const groupedMenu = useMemo(() => {
    const groups: Record<string, any[]> = {};

    for (const moduleGroup of pluginMenu) {
      if (moduleGroup.children) {
        groups[moduleGroup.label] = moduleGroup.children;
      }
    }

    return groups;
  }, [pluginMenu]);

  const toggleGroup = (groupName: string) => {
    setCollapsedGroups((prev) => ({
      ...prev,
      [groupName]: !prev[groupName],
    }));
  };

  return (
    <nav
      style={{
        width: '240px',
        height: '100vh',
        borderRight: '1px solid #e5e7eb',
        overflowY: 'auto',
        background: '#f9fafb',
      }}
    >
      {/* Plugin Menus */}
      {Object.entries(groupedMenu).map(([moduleName, items]) => {
        const moduleInfo = pluginMenu.find((m) => m.label === moduleName);

        return (
          <div key={moduleName}>
            {/* Module Header */}
            <button
              onClick={() => toggleGroup(moduleName)}
              style={{
                width: '100%',
                padding: '0.75rem 1rem',
                background: 'none',
                border: 'none',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                cursor: 'pointer',
                fontSize: '0.875rem',
                fontWeight: 600,
                color: '#374151',
              }}
            >
              <span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
                {moduleInfo?.icon && <Icon name={moduleInfo.icon} size={16} />}
                {moduleName}
              </span>
              <Icon
                name={collapsedGroups[moduleName] ? 'chevron-right' : 'chevron-down'}
                size={14}
              />
            </button>

            {/* Module Items */}
            {!collapsedGroups[moduleName] && (
              <div>
                {items.map((item) => (
                  <button
                    key={item.name}
                    onClick={() => navigate(item.route)}
                    style={{
                      width: '100%',
                      padding: '0.5rem 1rem 0.5rem 2.5rem',
                      background: location.pathname === item.route ? '#e5e7eb' : 'none',
                      border: 'none',
                      borderLeft:
                        location.pathname === item.route
                          ? '3px solid #3b82f6'
                          : '3px solid transparent',
                      color: location.pathname === item.route ? '#1f2937' : '#6b7280',
                      textAlign: 'left',
                      cursor: 'pointer',
                      fontSize: '0.875rem',
                      display: 'flex',
                      alignItems: 'center',
                      gap: '0.5rem',
                    }}
                  >
                    {item.icon && <Icon name={item.icon} size={14} />}
                    {item.label}
                    {item.badge && (
                      <span
                        style={{
                          marginLeft: 'auto',
                          padding: '0.125rem 0.5rem',
                          background: '#ef4444',
                          color: 'white',
                          borderRadius: '9999px',
                          fontSize: '0.75rem',
                        }}
                      >
                        {item.badge}
                      </span>
                    )}
                  </button>
                ))}
              </div>
            )}
          </div>
        );
      })}
    </nav>
  );
}
```

---

## Phase 3: Create WMS Plugin

### apps/wms/frontend/plugin.config.ts

```typescript
import { FrameworkMPlugin } from "@framework-m/plugin-sdk";

export default {
  name: "wms",
  version: "1.0.0",
  description: "Warehouse Management System",

  menu: [
    {
      name: "wms.dashboard",
      label: "WMS Dashboard",
      route: "/wms/dashboard",
      icon: "layout-dashboard",
      module: "Warehouse",
      order: 1,
    },
    {
      name: "wms.warehouse",
      label: "Warehouse",
      route: "/doctypes/wms.warehouse",
      icon: "warehouse",
      module: "Warehouse",
      category: "Masters",
      order: 10,
    },
    {
      name: "wms.bin_location",
      label: "Bin Location",
      route: "/doctypes/wms.bin_location",
      icon: "map-pin",
      module: "Warehouse",
      category: "Masters",
      order: 11,
    },
    {
      name: "wms.stock_entry",
      label: "Stock Entry",
      route: "/doctypes/wms.stock_entry",
      icon: "package",
      module: "Warehouse",
      category: "Transactions",
      order: 20,
    },
    {
      name: "wms.stock_transfer",
      label: "Stock Transfer",
      route: "/doctypes/wms.stock_transfer",
      icon: "truck",
      module: "Warehouse",
      category: "Transactions",
      order: 21,
    },
  ],

  routes: [
    {
      path: "/wms/dashboard",
      lazy: () => import("./pages/Dashboard"),
    },
    {
      path: "/wms/receiving",
      lazy: () => import("./pages/Receiving"),
    },
    {
      path: "/wms/putaway",
      lazy: () => import("./pages/Putaway"),
    },
  ],

  onInit: async () => {
    console.log("✓ WMS Plugin initialized");
  },
} satisfies FrameworkMPlugin;
```

### apps/wms/frontend/package.json

```json
{
  "name": "@my-company/wms",
  "version": "1.0.0",
  "private": true,
  "framework-m": {
    "plugin": "./dist/plugin.config.js",
    "type": "frontend-module"
  },
  "scripts": {
    "build": "tsc && vite build",
    "dev": "vite build --watch"
  },
  "dependencies": {
    "@framework-m/desk": "^0.1.0",
    "@framework-m/plugin-sdk": "^0.1.0",
    "react": "^19.1.0"
  },
  "devDependencies": {
    "typescript": "^5.8.0",
    "vite": "^6.3.0"
  }
}
```

---

## Testing the Implementation

### Step 1: Build Plugin SDK

```bash
cd libs/framework-m-plugin-sdk
pnpm install
pnpm build
```

### Step 2: Build WMS Plugin

```bash
cd apps/wms/frontend
pnpm install
pnpm build
```

### Step 3: Run Shell App

```bash
cd frontend
pnpm install
pnpm dev
```

### Expected Result

```
✓ Registered plugin: wms@1.0.0
✓ WMS Plugin initialized
✓ All plugins registered

Sidebar shows:
┌─────────────────────┐
│ 📦 Warehouse        │
│   ├─ Masters        │
│   │  ├─ Warehouse   │
│   │  └─ Bin Location│
│   └─ Transactions   │
│      ├─ Stock Entry │
│      └─ Stock Transfer
└─────────────────────┘
```

---

## Next Steps

1. **Add more plugins** - Create Personnel, Finance plugins
2. **Build Vite plugin** - Auto-discover plugins instead of manual imports
3. **Add favorites/recent** - Extend sidebar with user preferences
4. **Add search** - Filter menu items with fuzzy search
5. **Add tests** - Unit tests for PluginRegistry, integration tests for shell

---

## Common Issues & Solutions

### Issue: "usePluginMenu must be used within PluginRegistryProvider"

**Cause:** Sidebar used outside PluginRegistryProvider

**Fix:** Wrap App in PluginRegistryProvider:

```typescript
<PluginRegistryProvider registry={registry}>
  <Layout>
    <Sidebar /> {/* Now works */}
  </Layout>
</PluginRegistryProvider>
```

### Issue: Menu items not showing

**Cause:** Plugin not registered or menu empty

**Fix:** Check console for registration logs:

```typescript
console.log("Registered plugins:", registry.getAllPlugins());
console.log("Menu tree:", registry.getMenu());
```

### Issue: Routes not working

**Cause:** Routes not added to React Router

**Fix:** Ensure routes from registry are rendered:

```typescript
{registry.getRoutes().map((route, i) => (
  <Route key={i} {...route} />
))}
```


Document: docs/developer/package-frontend-guide.md

# Package Frontend Structure Guide

## Overview

Framework M supports multi-package UI composition, allowing Python packages to contribute frontend code that is automatically discovered and bundled during build or development.

## Package Structure

A Framework M package with frontend components follows this structure:

```
my-package/
├── pyproject.toml              # Package configuration
├── README.md
└── src/
    └── my_package/
        ├── __init__.py
        ├── doctypes/           # Backend DocTypes
        │   ├── __init__.py
        │   └── my_doctype.py
        └── frontend/           # Frontend code
            ├── index.ts        # Plugin entry point (required)
            ├── package.json    # Frontend dependencies
            ├── tsconfig.json   # TypeScript config
            ├── components/     # Shared components
            │   ├── MyCard.tsx
            │   └── MyForm.tsx
            ├── pages/          # Custom pages
            │   ├── MyList.tsx
            │   └── MyDetail.tsx
            └── hooks/          # Custom React hooks
                └── useMyData.ts
```

## Required Files

### 1. pyproject.toml

Register both backend and frontend entry points:

```toml
[project]
name = "my-package"
version = "1.0.0"
dependencies = [
    "framework-m>=0.1.0",
]

# Backend entry point (for DocType discovery)
[project.entry-points."framework_m.apps"]
my_package = "my_package:app"

# Frontend entry point (for UI discovery)
[project.entry-points."framework_m.frontend"]
my_package = "my_package.frontend:plugin"
```

### 2. frontend/index.ts

The main plugin entry point that registers your UI components:

```typescript
import { registerPlugin } from "@framework-m/core";
import { MyCard } from "./components/MyCard";
import { MyForm } from "./components/MyForm";
import { MyListPage } from "./pages/MyList";
import { MyDetailPage } from "./pages/MyDetail";

// Export plugin metadata
export const plugin = {
  name: "my_package",
  version: "1.0.0",
};

// Register plugin on import
registerPlugin({
  name: "my_package",
  version: "1.0.0",

  // Register custom pages
  pages: [
    {
      path: "/app/my-doctype/list",
      component: MyListPage,
    },
    {
      path: "/app/my-doctype/:id",
      component: MyDetailPage,
    },
  ],

  // Register components for use by other plugins
  components: {
    MyCard,
    MyForm,
  },

  // Inject into slots defined by other plugins
  slots: [
    {
      slot: "desk.sidebar.bottom",
      component: MyCard,
      priority: 10,
    },
  ],
});
```

## Development Workflow

### Local Development

Install your package in development mode and run the dev server:

```bash
# Install as editable package
pip install -e ./libs/my-package

# Run dev server (auto-discovers all plugins)
m dev

# Your plugin UI is now available at http://localhost:5173
```

The dev server will:

- Discover your package via `framework_m.frontend` entry point
- Generate Vite config with path aliases
- Enable Hot Module Replacement (HMR) for your TypeScript/React code

### Building for Production

```bash
# Build includes all discovered plugins
m build --output ./dist

# Result: Single optimized bundle with all plugins
```

## Frontend Dependencies

### package.json

Each package can declare its own frontend dependencies:

```json
{
  "name": "@my-package/frontend",
  "version": "1.0.0",
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "@framework-m/core": "^0.1.0"
  },
  "dependencies": {
    "date-fns": "^2.30.0",
    "zustand": "^4.5.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "typescript": "^5.0.0"
  }
}
```

**Important:** Use `peerDependencies` for shared libraries (React, Framework M core) to avoid duplication in the final bundle.

### TypeScript Configuration

Create a `tsconfig.json` for type checking:

```json
{
  "extends": "@framework-m/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./",
    "baseUrl": ".",
    "paths": {
      "@framework-m/core": ["../../framework-m/frontend/src"],
      "@/*": ["./src/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", "dist"]
}
```

## Import Patterns

### Importing from Framework M Core

```typescript
import { registerPlugin, useDocType, Button } from "@framework-m/core";
```

### Importing from Other Packages

```typescript
// Import components from business-m package
import { CustomerCard } from "@business-m/frontend/components/CustomerCard";
import { useInvoice } from "@business-m/frontend/hooks/useInvoice";
```

The build system automatically resolves these imports via Vite path aliases.

## Best Practices

### 1. Component Isolation

Keep components self-contained with their own styles:

```typescript
// components/MyCard.tsx
import { Card } from "@framework-m/core";
import styles from "./MyCard.module.css";

export function MyCard({ data }) {
  return (
    <Card className={styles.card}>
      {/* Component content */}
    </Card>
  );
}
```

### 2. Type Safety

Export TypeScript types for components consumed by other packages:

```typescript
// types/index.ts
export interface MyCardProps {
  data: MyData;
  onAction?: (id: string) => void;
}

// components/MyCard.tsx
import type { MyCardProps } from "../types";

export function MyCard({ data, onAction }: MyCardProps) {
  // Implementation
}
```

### 3. Tree-Shaking

Use named exports to enable tree-shaking:

```typescript
// ✅ Good: Named exports
export { MyCard } from "./components/MyCard";
export { MyForm } from "./components/MyForm";

// ❌ Bad: Default exports
export default {
  MyCard,
  MyForm,
};
```

### 4. Code Splitting

Use dynamic imports for large components:

```typescript
import { lazy } from "react";

const LargeReport = lazy(() => import("./components/LargeReport"));

registerPlugin({
  name: "my_package",
  pages: [
    {
      path: "/app/reports/large",
      component: LargeReport, // Auto-code-split
    },
  ],
});
```

## Testing

### Unit Tests

Test components in isolation:

```typescript
// components/__tests__/MyCard.test.tsx
import { render } from "@testing-library/react";
import { MyCard } from "../MyCard";

describe("MyCard", () => {
  it("renders data correctly", () => {
    const { getByText } = render(<MyCard data={{ title: "Test" }} />);
    expect(getByText("Test")).toBeInTheDocument();
  });
});
```

### Integration Tests

Test plugin registration:

```typescript
// __tests__/plugin.test.ts
import { getRegisteredPlugins } from "@framework-m/core";
import { plugin } from "../index";

describe("Plugin Registration", () => {
  it("registers with correct metadata", () => {
    const plugins = getRegisteredPlugins();
    const myPlugin = plugins.find(p => p.name === "my_package");

    expect(myPlugin).toBeDefined();
    expect(myPlugin.version).toBe("1.0.0");
  });
});
```

## Publishing

### 1. Build the Package

```bash
# Ensure frontend code is included in package
pip install build
python -m build
```

### 2. Verify Package Contents

```bash
tar -tzf dist/my_package-1.0.0.tar.gz | grep frontend
# Should show:
# my_package/frontend/index.ts
# my_package/frontend/components/...
# my_package/frontend/pages/...
```

### 3. Publish to PyPI

```bash
pip install twine
twine upload dist/*
```

## Next Steps

- [Plugin Composition Patterns](./plugin-composition-patterns.md) - Learn how to compose and override UI from multiple packages
- [Migration Path](../adr/0010-multi-package-ui-composition.md#migration-path) - Migrate existing apps/ to packages
- [Example: business-m Package](../examples/business-m-package.md) - Complete working example


Document: docs/developer/plugin-package-architecture.md

# Plugin Package Architecture - Quick Reference

## Visual Architecture Diagram

```
┌─────────────────────────────────────────────────────────────────┐
│                      EXTERNAL DEPENDENCIES                       │
│  @refinedev/*, react, react-router-dom, @tanstack/react-query  │
└────────────────────────────┬────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│               @framework-m/desk (CORE UI LIBRARY)               │
│  📦 Published to GitLab npm: @framework-m/desk@0.1.0           │
│  📁 Location: libs/framework-m-desk/                            │
├─────────────────────────────────────────────────────────────────┤
│  Responsibilities:                                              │
│  • Refine.dev data provider (REST API)                         │
│  • Auth provider (JWT, sessions)                               │
│  • Live provider (WebSocket)                                   │
│  • Base components: <ListView>, <FormView>, <ShowView>         │
│  • Hooks: useDocType(), useMetadata(), useWorkflow()           │
│  • Utilities: formatDate(), validateForm(), etc.               │
├─────────────────────────────────────────────────────────────────┤
│  Who uses it:                                                   │
│  ✓ Shell application (frontend/)                               │
│  ✓ All app plugins (apps/*/frontend/)                          │
│  ✓ Any Framework M project                                     │
├─────────────────────────────────────────────────────────────────┤
│  Breaking changes?  NO - Existing users unaffected             │
└────────────────────────────┬────────────────────────────────────┘
                             │
                ┌────────────┴──────────────┐
                │                           │
                ▼                           ▼
┌───────────────────────────┐   ┌──────────────────────────────────┐
│  @framework-m/plugin-sdk  │   │   App Plugins (Workspace)        │
│  (PLUGIN INFRASTRUCTURE)  │   │   NOT published to npm           │
│                           │   │                                  │
│  📦 Published: GitLab npm │   │  Example: @my-company/wms        │
│  📁 libs/plugin-sdk/      │   │  📁 apps/wms/frontend/           │
├───────────────────────────┤   ├──────────────────────────────────┤
│  Exports:                 │   │  Structure:                      │
│  • PluginRegistry         │   │  • plugin.config.ts              │
│  • ServiceContainer       │   │  • package.json (framework-m)    │
│  • usePluginMenu()        │   │  • src/pages/                    │
│  • useService()           │   │  • src/components/               │
│  • usePlugin()            │   │  • src/services/                 │
│  • FrameworkMPlugin type  │   ├──────────────────────────────────┤
│  • MenuItem type          │   │  Defines:                        │
├───────────────────────────┤   │  • Menu items                    │
│  Used by:                 │   │  • Routes                        │
│  ✓ Shell app              │   │  • Services                      │
│  ✓ Vite plugin            │   │  • Providers                     │
│  ✓ App plugins            │   ├──────────────────────────────────┤
└───────────────────────────┘   │  Examples:                       │
                │                │  • apps/wms/frontend/            │
                │                │  • apps/personnel/frontend/      │
                │                │  • apps/finance/frontend/        │
                │                └──────────────────────────────────┘
                │                             │
                │                             │
                ▼                             │
┌───────────────────────────────────────┐     │
│  @framework-m/vite-plugin             │     │
│  (BUILD-TIME TOOLING)                 │     │
│                                       │     │
│  📦 Published: GitLab npm             │     │
│  📁 libs/vite-plugin/                 │     │
├───────────────────────────────────────┤     │
│  Responsibilities:                    │     │
│  • Scan workspace for plugin packages │     │
│  • Generate virtual module            │     │
│  • Configure code-splitting           │     │
│  • Setup HMR for plugins              │     │
├───────────────────────────────────────┤     │
│  Used by:                             │     │
│  ✓ Shell app (vite.config.ts only)   │     │
└───────────────┬───────────────────────┘     │
                │                             │
                │ discovers                   │ imports
                │                             │
                ▼                             │
┌─────────────────────────────────────────────┴───────────────────┐
│              frontend/ (SHELL APPLICATION)                       │
│              NOT published - Static build output                 │
│              📁 frontend/                                        │
├──────────────────────────────────────────────────────────────────┤
│  Responsibilities:                                               │
│  • Bootstrap PluginRegistry                                      │
│  • Compose all plugins into single UI                           │
│  • Provide base layout (Sidebar, Header)                        │
│  • Handle auth flow                                              │
│  • Error boundaries                                              │
├──────────────────────────────────────────────────────────────────┤
│  Dependencies:                                                   │
│  • @framework-m/desk ^0.1.0                                     │
│  • @framework-m/plugin-sdk ^0.1.0                               │
│  • @framework-m/vite-plugin ^0.1.0 (devDependency)              │
│  • Workspace plugins (apps/*/frontend/) via symlinks            │
├──────────────────────────────────────────────────────────────────┤
│  Build Output (frontend/dist/):                                 │
│  • index.html                                                    │
│  • assets/main-[hash].js         (shell + framework)           │
│  • assets/wms-[hash].js           (WMS plugin - lazy)           │
│  • assets/personnel-[hash].js     (Personnel plugin - lazy)     │
│                                                                  │
│  Deployed as:                                                    │
│  • Python package (m build → pip install)                       │
│  • CDN (aws s3 sync dist/ s3://bucket/)                         │
│  • Container (COPY dist/ /app/static)                           │
└──────────────────────────────────────────────────────────────────┘
```

## Data Flow: How Plugin Menus Work

```
┌─────────────────────────────────────────────────────────────────┐
│  Step 1: Plugin Definition                                      │
│  apps/wms/frontend/plugin.config.ts                             │
├──────────────────────────────────────────────────────────────────┤
│  export default {                                                │
│    name: 'wms',                                                  │
│    menu: [                                                       │
│      {                                                           │
│        name: 'wms.warehouse',                                    │
│        label: 'Warehouse',                                       │
│        route: '/doctypes/wms.warehouse',                         │
│        module: 'Inventory',                                      │
│      }                                                           │
│    ]                                                             │
│  } satisfies FrameworkMPlugin                                    │
└────────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Step 2: Build-Time Discovery                                   │
│  @framework-m/vite-plugin scans workspace                       │
├──────────────────────────────────────────────────────────────────┤
│  1. Find all package.json files                                 │
│  2. Filter those with "framework-m" metadata                    │
│  3. Collect plugin.config.ts paths                              │
│  4. Generate virtual module:                                    │
│                                                                  │
│  // virtual:framework-m-plugins                                 │
│  import wms from 'apps/wms/frontend/dist/plugin.config.js';     │
│  import personnel from 'apps/personnel/.../plugin.config.js';   │
│  export default [wms, personnel];                               │
└────────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Step 3: Runtime Registration                                   │
│  frontend/src/App.tsx bootstrap()                               │
├──────────────────────────────────────────────────────────────────┤
│  import plugins from 'virtual:framework-m-plugins';             │
│                                                                  │
│  const registry = new PluginRegistry();                         │
│  for (const plugin of plugins) {                                │
│    await registry.register(plugin);                             │
│  }                                                               │
│                                                                  │
│  // Registry now contains:                                      │
│  // - wms.menu = [{name: 'wms.warehouse', ...}]                 │
│  // - personnel.menu = [...]                                    │
└────────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Step 4: Menu Merging                                           │
│  PluginRegistry.getMenu()                                       │
├──────────────────────────────────────────────────────────────────┤
│  1. Collect all menu items from all plugins                     │
│  2. Group by module:                                            │
│     - Inventory: [wms.warehouse, wms.bin_location, ...]         │
│     - HR: [personnel.employee, ...]                             │
│  3. Sort by order                                               │
│  4. Return merged tree:                                         │
│                                                                  │
│  [                                                               │
│    {                                                             │
│      name: 'inventory',                                          │
│      label: 'Inventory',                                         │
│      children: [                                                 │
│        { name: 'wms.warehouse', label: 'Warehouse' },           │
│        { name: 'wms.bin_location', label: 'Bin Location' }      │
│      ]                                                           │
│    },                                                            │
│    { name: 'hr', label: 'HR', children: [...] }                 │
│  ]                                                               │
└────────────────────────────┬─────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  Step 5: UI Rendering                                           │
│  frontend/src/layout/Sidebar.tsx                                │
├──────────────────────────────────────────────────────────────────┤
│  function Sidebar() {                                            │
│    const pluginMenu = usePluginMenu(); // Hook from plugin-sdk  │
│                                                                  │
│    return (                                                      │
│      <nav>                                                       │
│        {pluginMenu.map(moduleGroup => (                         │
│          <ModuleSection key={moduleGroup.name}>                 │
│            <Icon name={moduleGroup.icon} />                     │
│            {moduleGroup.label}                                   │
│            {moduleGroup.children.map(item => (                  │
│              <MenuItem                                           │
│                route={item.route}                               │
│                label={item.label}                               │
│              />                                                  │
│            ))}                                                   │
│          </ModuleSection>                                        │
│        ))}                                                       │
│      </nav>                                                      │
│    )                                                             │
│  }                                                               │
└──────────────────────────────────────────────────────────────────┘
```

## Package Dependency Chain

```
Level 0 (External):
  react, react-dom, @refinedev/*, @tanstack/react-query
       │
       ▼
Level 1 (Core):
  @framework-m/desk
       │
       ├──────────────────┐
       │                  │
       ▼                  ▼
Level 2 (Plugin System):
  @framework-m/plugin-sdk    App Plugins (@my-company/wms)
       │                         │
       ├─────────────────────────┤
       │                         │
       ▼                         │
Level 3 (Build Tools):
  @framework-m/vite-plugin       │
       │                         │
       └────────┬────────────────┘
                │
                ▼
Level 4 (Shell):
  frontend/ (Shell App)
       │
       ▼
Level 5 (Build Output):
  frontend/dist/ (Static Assets)
```

## Workspace Structure Example

```
my-erp-project/
├── pnpm-workspace.yaml
├── package.json
│
├── frontend/                           # Shell Application
│   ├── package.json
│   │   └── dependencies:
│   │       ├── @framework-m/desk: ^0.1.0
│   │       └── @framework-m/plugin-sdk: ^0.1.0
│   ├── vite.config.ts
│   │   └── plugins: [frameworkMPlugin()]
│   ├── src/
│   │   ├── App.tsx                    # Bootstrap plugins
│   │   └── layout/Sidebar.tsx         # Uses usePluginMenu()
│   └── dist/                          # Build output
│
├── apps/
│   ├── wms/
│   │   ├── backend/                   # Python app
│   │   │   └── pyproject.toml
│   │   └── frontend/                  # WMS Plugin
│   │       ├── package.json
│   │       │   ├── name: @my-company/wms
│   │       │   ├── private: true
│   │       │   ├── framework-m:
│   │       │   │   └── plugin: ./dist/plugin.config.js
│   │       │   └── dependencies:
│   │       │       ├── @framework-m/desk: ^0.1.0
│   │       │       └── @framework-m/plugin-sdk: ^0.1.0
│   │       ├── plugin.config.ts       # Menu, routes, services
│   │       └── src/
│   │           ├── pages/
│   │           ├── components/
│   │           └── services/
│   │
│   └── personnel/
│       ├── backend/
│       └── frontend/                  # Personnel Plugin
│           ├── package.json
│           ├── plugin.config.ts
│           └── src/
│
└── libs/                              # Framework packages
    ├── framework-m-desk/              # Published to npm
    │   ├── package.json
    │   │   └── name: @framework-m/desk
    │   └── src/
    ├── framework-m-plugin-sdk/        # Published to npm
    │   ├── package.json
    │   │   └── name: @framework-m/plugin-sdk
    │   └── src/
    └── framework-m-vite-plugin/       # Published to npm
        ├── package.json
        │   └── name: @framework-m/vite-plugin
        └── src/
```

## Package Publishing Matrix

| Package                    | Location                        | Published? | Registry   | Versioning     |
| -------------------------- | ------------------------------- | ---------- | ---------- | -------------- |
| `@framework-m/desk`        | `libs/framework-m-desk/`        | ✅ Yes     | GitLab npm | Follows Python |
| `@framework-m/plugin-sdk`  | `libs/framework-m-plugin-sdk/`  | ✅ Yes     | GitLab npm | Independent    |
| `@framework-m/vite-plugin` | `libs/framework-m-vite-plugin/` | ✅ Yes     | GitLab npm | Independent    |
| `frontend/` (Shell)        | `frontend/`                     | ❌ No      | -          | -              |
| `@my-company/wms`          | `apps/wms/frontend/`            | ❌ Never   | Workspace  | Private        |
| `@my-company/personnel`    | `apps/personnel/frontend/`      | ❌ Never   | Workspace  | Private        |

## When to Use Each Package

### Use `@framework-m/desk` when:

- ✅ Building any Framework M frontend (single or multi-app)
- ✅ Need data provider for REST API
- ✅ Need auth provider
- ✅ Need base DocType components
- ✅ Want to use Framework M hooks

### Use `@framework-m/plugin-sdk` when:

- ✅ Building multi-app project with plugins
- ✅ Need to access plugin menus/routes
- ✅ Want to register services
- ✅ Creating reusable app modules

### Use `@framework-m/vite-plugin` when:

- ✅ Setting up shell app build
- ✅ Need auto-discovery of plugins
- ✅ Want code-splitting per plugin
- ✅ Building multi-app project

### Don't use plugins if:

- ❌ Single-app project (just use `@framework-m/desk` directly)
- ❌ Simple customization (add pages directly to `frontend/src/`)
- ❌ Prototyping (overhead not worth it)

## Migration Path

### Existing Single-App Projects (No Changes Needed)

```json
// Current setup - Still works!
{
  "name": "my-app-frontend",
  "dependencies": {
    "@refinedev/core": "^5.0.0",
    "react": "^19.0.0"
  }
}

// Add desk when ready (optional)
{
  "dependencies": {
    "@framework-m/desk": "^0.1.0",  // ← Just add this
    "react": "^19.0.0"
  }
}
```

### New Multi-App Projects

```json
// Shell app
{
  "name": "frontend",
  "dependencies": {
    "@framework-m/desk": "^0.1.0",
    "@framework-m/plugin-sdk": "^0.1.0"
  },
  "devDependencies": {
    "@framework-m/vite-plugin": "^0.1.0"
  }
}

// Plugin app (workspace package)
{
  "name": "@my-company/wms",
  "private": true,
  "framework-m": {
    "plugin": "./dist/plugin.config.js"
  },
  "dependencies": {
    "@framework-m/desk": "workspace:^0.1.0",
    "@framework-m/plugin-sdk": "workspace:^0.1.0"
  }
}
```

## Quick Decision Tree

```
Are you building a single app?
├─ Yes → Just use @framework-m/desk
│         No plugin system needed
│
└─ No (multi-app) → Use plugin system
    │
    ├─ Shell app needs:
    │   ├─ @framework-m/desk
    │   ├─ @framework-m/plugin-sdk
    │   └─ @framework-m/vite-plugin (devDependency)
    │
    └─ Plugin apps need:
        ├─ @framework-m/desk
        └─ @framework-m/plugin-sdk
```


Document: docs/developer/per-tenant-locales.md

---
title: Per-Tenant Locales
sidebar_position: 5
---

# Per-Tenant Locales and Translation Overrides
and override system translations to match their specific terminology,
industry language, or brand voice.

## Overview

Framework M supports tenant-specific localization through two mechanisms:

1. **Tenant Default Locale**: Set organization-wide language preference
2. **Tenant Translation Overrides**: Customize specific translations

## Setting Tenant Default Locale

### Backend: Configure Tenant

Set the default locale in the tenant's attributes:

```python
from framework_m.core.interfaces.tenant import TenantContext

# Configure tenant with Hindi as default locale
tenant = TenantContext(
    tenant_id="acme-corp",
    attributes={
        "default_locale": "hi",  # Hindi
        "plan": "enterprise",
        "features": ["advanced_reports"]
    }
)
```

### Locale Resolution Priority

When a user makes a request, the locale is resolved in this order:

1. **Accept-Language header** (browser preference)
2. **User.locale field** (personal preference)
3. **Tenant default_locale** (organization default) ← Tenant setting
4. **System DEFAULT_LOCALE** (fallback to "en")

### Example: Healthcare Organization

```python
# Healthcare tenant defaults to Tamil
healthcare_tenant = TenantContext(
    tenant_id="healthcare-india",
    attributes={
        "default_locale": "ta",  # Tamil
        "industry": "healthcare"
    }
)

# All users in this tenant see Tamil by default
# unless they:
# - Set their browser to a different language
# - Set their personal locale preference
```

## Tenant Translation Overrides

Tenants can provide custom translations to override system translations.
This is useful for:

- Industry-specific terminology
- Brand-specific wording
- Localized company names in messages

### Creating Tenant Translations

#### Option 1: Via API

```bash
# Create tenant-specific translation
POST /api/v1/TenantTranslation
Authorization: Bearer <token>
X-Tenant-ID: acme-corp

{
  "tenant_id": "acme-corp",
  "source_text": "Customer",
  "translated_text": "Patient",
  "locale": "en",
  "context": "field_label"
}
```

#### Option 2: Via Python Code

```python
from framework_m.core.doctypes.tenant_translation import TenantTranslation

# Healthcare tenant uses "Patient" instead of "Customer"
patient_translation = TenantTranslation(
    tenant_id="healthcare-india",
    source_text="Customer",
    translated_text="Patient",
    locale="en",
    context="field_label"
)

# Same in Tamil
patient_translation_ta = TenantTranslation(
    tenant_id="healthcare-india",
    source_text="Customer",
    translated_text="நோயாளி",  # Patient in Tamil
    locale="ta",
    context="field_label"
)
```

### Translation Priority

When translating text, the system checks in this order:

1. **TenantTranslation** (tenant-specific override)
2. **Translation** (system-wide translation)
3. **Default parameter** (if provided)
4. **Source text** (original text)

### Example: Retail vs Healthcare

```python
# System translation (default)
Translation(
    source_text="Customer",
    translated_text="ग्राहक",  # Customer in Hindi
    locale="hi",
    context="field_label"
)

# Retail tenant override (uses "Client")
TenantTranslation(
    tenant_id="retail-corp",
    source_text="Customer",
    translated_text="क्लाइंट",  # Client in Hindi
    locale="hi",
    context="field_label"
)

# Healthcare tenant override (uses "Patient")
TenantTranslation(
    tenant_id="healthcare-india",
    source_text="Customer",
    translated_text="रोगी",  # Patient in Hindi
    locale="hi",
    context="field_label"
)
```

### Result

When each tenant's users see the "Customer" field:

- **Retail tenant**: "क्लाइंट" (Client)
- **Healthcare tenant**: "रोगी" (Patient)
- **Other tenants**: "ग्राहक" (Customer - system default)

## Managing Tenant Translations

### List Tenant Translations

```bash
# Get all translations for a tenant
GET /api/v1/TenantTranslation?filters=[{"field":"tenant_id","operator":"eq","value":"acme-corp"}]
Authorization: Bearer <token>
X-Tenant-ID: acme-corp
```

### Update Tenant Translation

```bash
# Update existing translation
PUT /api/v1/TenantTranslation/{id}
Authorization: Bearer <token>
X-Tenant-ID: acme-corp

{
  "translated_text": "Updated translation"
}
```

### Delete Tenant Translation

```bash
# Remove custom translation (falls back to system translation)
DELETE /api/v1/TenantTranslation/{id}
Authorization: Bearer <token>
X-Tenant-ID: acme-corp
```

## Row-Level Security

TenantTranslation DocType has RLS enabled:

```python
class TenantTranslation(BaseDocType):
    class Meta:
        apply_rls = True
        rls_field = "tenant_id"
```

This ensures:

- Tenants can only see/modify their own translations
- System automatically filters by tenant_id
- No cross-tenant data leakage

## Frontend Usage

### Automatic Translation

Field labels in forms are automatically translated based on:

1. User's resolved locale
2. Tenant overrides (if any)

```typescript
// Frontend fetches metadata
const meta = await fetch('/api/meta/Invoice', {
  headers: {
    'X-Tenant-ID': 'healthcare-india',
    'Accept-Language': 'ta'
  }
});

// Response includes translated field labels
{
  "doctype": "Invoice",
  "locale": "ta",
  "schema": {
    "properties": {
      "customer": {
        "description": "நோயாளி",  // "Patient" (tenant override)
        "type": "string"
      }
    }
  }
}
```

### Translation Context

Use context to disambiguate translations:

```python
# Button label
TenantTranslation(
    tenant_id="acme-corp",
    source_text="Save",
    translated_text="सुरक्षित करें",  # "Secure/Protect" emphasis
    locale="hi",
    context="button"
)

# Field label
TenantTranslation(
    tenant_id="acme-corp",
    source_text="Save",
    translated_text="बचत",  # "Savings" (financial context)
    locale="hi",
    context="field_label"
)
```

## Best Practices

### 1. Start with System Translations

Only create tenant overrides when necessary:

```python
# Good: Override for industry-specific term
TenantTranslation(
    tenant_id="medical-corp",
    source_text="Customer",
    translated_text="Patient",
    locale="en"
)

# Avoid: Duplicating system translations unnecessarily
# (use system Translation instead)
```

### 2. Use Consistent Context

```python
# Consistent context for all field labels
context="field_label"

# Consistent context for all buttons
context="button"

# Consistent context for all messages
context="message"
```

### 3. Document Tenant Customizations

Keep a record of why overrides exist:

```python
# Document the reason in comments or separate docs
TenantTranslation(
    tenant_id="healthcare-india",
    source_text="Total Amount",
    translated_text="மொத்த கட்டணம்",  # "Total Fee" per healthcare terminology
    locale="ta",
    context="field_label"
)
```

### 4. Test in Multiple Locales

```bash
# Test each tenant's locale
curl -H "X-Tenant-ID: healthcare-india" \
     -H "Accept-Language: ta" \
     /api/meta/Invoice

curl -H "X-Tenant-ID: retail-corp" \
     -H "Accept-Language: hi" \
     /api/meta/Invoice
```

## Migration from Single-Tenant

If migrating from single-tenant to multi-tenant:

1. **Review existing translations**: Decide which are tenant-specific
2. **Create tenant translations**: Move tenant-specific translations to TenantTranslation
3. **Keep system translations**: Keep common translations in Translation
4. **Test thoroughly**: Verify each tenant sees correct translations

## See Also

- `framework_m.core.doctypes.tenant_translation` - TenantTranslation DocType
- `framework_m.adapters.i18n.DefaultI18nAdapter` - Translation adapter with tenant support
- `framework_m.adapters.web.middleware.locale` - Locale resolution middleware
- `docs/developer/locale-resolution.md` - Complete locale resolution guide