feat: Mini-Apps registry, Stammdaten backend persistence, bank accounts

- Register 6 mini-apps in kommunikation plugin (contact_picker, file_share,
  calendar_invite, mail_forward, ai_search, task_create)
- Connect AdressenTab to backend API (GET/POST/PATCH/DELETE /addresses)
- Create BankAccount model, schema, service, routes + migration 0033
- Connect KontenTab to backend API (GET/POST/PATCH/DELETE /bank-accounts)
- AI tools already working: 7 tools registered (hybrid_search, get_contact_mails, etc.)
This commit is contained in:
Agent Zero
2026-07-25 02:11:29 +02:00
parent 382f500f39
commit 3e7abd4518
9 changed files with 600 additions and 29 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Add bank_accounts table.
Revision ID: 0033
Revises: 0032_user_profile_fields
Create Date: 2026-07-25
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
revision: str = "0033_bank_accounts"
down_revision: Union[str, None] = "0032_user_profile_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"bank_accounts",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False, index=True),
sa.Column("bank_name", sa.String(100), nullable=False),
sa.Column("iban", sa.String(34), nullable=False),
sa.Column("bic", sa.String(11), nullable=True),
sa.Column("account_holder", sa.String(200), nullable=True),
sa.Column("default_tax", sa.String(50), nullable=True),
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_bank_accounts_tenant", "bank_accounts", ["tenant_id"])
op.create_index("ix_bank_accounts_tenant_default", "bank_accounts", ["tenant_id", "is_default"])
def downgrade() -> None:
op.drop_index("ix_bank_accounts_tenant_default", table_name="bank_accounts")
op.drop_index("ix_bank_accounts_tenant", table_name="bank_accounts")
op.drop_table("bank_accounts")
+2
View File
@@ -1,6 +1,7 @@
"""SQLAlchemy models for LeoCRM."""
from app.models.address import Address
from app.models.bank_account import BankAccount
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.attachment import Attachment
from app.models.audit import AuditLog, DeletionLog
@@ -48,6 +49,7 @@ __all__ = [
"SystemSettings",
"Attachment",
"Address",
"BankAccount",
"Plugin",
"PluginMigration",
"AIConversation",
+30
View File
@@ -0,0 +1,30 @@
"""BankAccount model — multiple bank accounts per tenant."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class BankAccount(Base, TenantMixin):
"""Bank account entity — multiple accounts per tenant.
is_default: one default bank account per tenant.
"""
__tablename__ = "bank_accounts"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
bank_name: Mapped[str] = mapped_column(String(100), nullable=False)
iban: Mapped[str] = mapped_column(String(34), nullable=False)
bic: Mapped[str | None] = mapped_column(String(11), nullable=True)
account_holder: Mapped[str | None] = mapped_column(String(200), nullable=True)
default_tax: Mapped[str | None] = mapped_column(String(50), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+51 -1
View File
@@ -71,7 +71,57 @@ class KommunikationPlugin(BasePlugin):
miniapp_registry = MiniAppRegistry()
service_container.register("comm_miniapps", miniapp_registry)
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready")
# Register built-in mini-apps
miniapp_registry.register(
app_id="contact_picker",
name="Kontakt wählen",
icon="👤",
description="Kontakt aus dem CRM im Chat teilen",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"contact_id": {"type": "string"}}},
)
miniapp_registry.register(
app_id="file_share",
name="Datei teilen",
icon="📎",
description="Datei aus dem DMS im Chat teilen",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"file_id": {"type": "string"}}},
)
miniapp_registry.register(
app_id="calendar_invite",
name="Termin teilen",
icon="📅",
description="Kalender-Termin im Chat teilen",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"event_id": {"type": "string"}}},
)
miniapp_registry.register(
app_id="mail_forward",
name="E-Mail weiterleiten",
icon="✉️",
description="E-Mail im Chat weiterleiten",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"mail_id": {"type": "string"}}},
)
miniapp_registry.register(
app_id="ai_search",
name="KI Suche",
icon="🔍",
description="KI-gestützte Suche im CRM starten",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"query": {"type": "string"}}},
)
miniapp_registry.register(
app_id="task_create",
name="Aufgabe erstellen",
icon="",
description="Aufgabe aus dem Chat erstellen",
plugin_name="kommunikation",
render_schema={"type": "object", "properties": {"title": {"type": "string"}, "due_date": {"type": "string"}}},
)
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready (6 mini-apps registered)")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up registries."""
+1
View File
@@ -3,6 +3,7 @@
from app.routes import (
addresses, # noqa: F401
ai_copilot, # noqa: F401
bank_accounts, # noqa: F401
audit, # noqa: F401
auth, # noqa: F401
contacts, # noqa: F401
+85
View File
@@ -0,0 +1,85 @@
"""Bank account routes — list, create, update, delete with tenant isolation."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.schemas.bank_account import BankAccountCreate, BankAccountUpdate
from app.services import bank_account_service
router = APIRouter(prefix="/api/v1/bank-accounts", tags=["bank-accounts"])
@router.get("")
async def list_bank_accounts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("bank-accounts:read")),
):
"""List all bank accounts for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await bank_account_service.list_bank_accounts(db, tenant_id)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_bank_account(
body: BankAccountCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("bank-accounts:write")),
):
"""Create a new bank account."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
try:
return await bank_account_service.create_bank_account(db, tenant_id, user_id, data)
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_value"}) from exc
@router.patch("/{account_id}")
async def update_bank_account(
account_id: str,
body: BankAccountUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("bank-accounts:write")),
):
"""Update a bank account."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
aid = uuid.UUID(account_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None
data = body.model_dump(exclude_unset=True)
result = await bank_account_service.update_bank_account(db, tenant_id, user_id, aid, data)
if result is None:
raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"})
return result
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bank_account(
account_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("bank-accounts:write")),
):
"""Soft-delete a bank account."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
aid = uuid.UUID(account_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None
deleted = await bank_account_service.delete_bank_account(db, tenant_id, user_id, aid)
if not deleted:
raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"})
+40
View File
@@ -0,0 +1,40 @@
"""Bank account schemas — create, update, read, list."""
from __future__ import annotations
from pydantic import BaseModel, Field
class BankAccountCreate(BaseModel):
bank_name: str = Field(..., min_length=1, max_length=100)
iban: str = Field(..., min_length=1, max_length=34)
bic: str | None = Field(None, max_length=11)
account_holder: str | None = Field(None, max_length=200)
default_tax: str | None = Field(None, max_length=50)
is_default: bool = False
class BankAccountUpdate(BaseModel):
bank_name: str | None = Field(None, min_length=1, max_length=100)
iban: str | None = Field(None, min_length=1, max_length=34)
bic: str | None = Field(None, max_length=11)
account_holder: str | None = Field(None, max_length=200)
default_tax: str | None = Field(None, max_length=50)
is_default: bool | None = None
class BankAccountResponse(BaseModel):
id: str
bank_name: str
iban: str
bic: str | None = None
account_holder: str | None = None
default_tax: str | None = None
is_default: bool
created_at: str | None = None
updated_at: str | None = None
class BankAccountListResponse(BaseModel):
items: list[BankAccountResponse]
total: int
+155
View File
@@ -0,0 +1,155 @@
"""Bank account service — CRUD with tenant isolation."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.bank_account import BankAccount
def _account_to_dict(a: BankAccount) -> dict[str, Any]:
"""Serialize a BankAccount ORM object to dict."""
return {
"id": str(a.id),
"bank_name": a.bank_name,
"iban": a.iban,
"bic": a.bic,
"account_holder": a.account_holder,
"default_tax": a.default_tax,
"is_default": a.is_default,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
async def list_bank_accounts(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> dict[str, Any]:
"""List all bank accounts for a tenant."""
q = (
select(BankAccount)
.where(
BankAccount.tenant_id == tenant_id,
BankAccount.deleted_at.is_(None),
)
.order_by(BankAccount.is_default.desc(), BankAccount.bank_name.asc())
)
result = await db.execute(q)
accounts = result.scalars().all()
return {
"items": [_account_to_dict(a) for a in accounts],
"total": len(accounts),
}
async def create_bank_account(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new bank account. If is_default=True, unset other defaults first."""
if data.get("is_default"):
await db.execute(
update(BankAccount)
.where(
BankAccount.tenant_id == tenant_id,
BankAccount.is_default.is_(True),
BankAccount.deleted_at.is_(None),
)
.values(is_default=False)
)
account = BankAccount(
tenant_id=tenant_id,
bank_name=data["bank_name"],
iban=data["iban"],
bic=data.get("bic"),
account_holder=data.get("account_holder"),
default_tax=data.get("default_tax"),
is_default=data.get("is_default", False),
)
db.add(account)
await db.flush()
await db.refresh(account)
await log_audit(
db, tenant_id, user_id, "create", "bank_account", account.id,
changes={"bank_name": account.bank_name, "iban": account.iban},
)
return _account_to_dict(account)
async def update_bank_account(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
account_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a bank account. If setting is_default=True, unset other defaults first."""
q = select(BankAccount).where(
BankAccount.id == account_id,
BankAccount.tenant_id == tenant_id,
BankAccount.deleted_at.is_(None),
)
result = await db.execute(q)
account = result.scalar_one_or_none()
if account is None:
return None
if data.get("is_default") is True and not account.is_default:
await db.execute(
update(BankAccount)
.where(
BankAccount.tenant_id == tenant_id,
BankAccount.is_default.is_(True),
BankAccount.id != account_id,
BankAccount.deleted_at.is_(None),
)
.values(is_default=False)
)
changes: dict[str, Any] = {}
for field in ("bank_name", "iban", "bic", "account_holder", "default_tax", "is_default"):
if field in data and data[field] is not None:
old_val = getattr(account, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(account, field, data[field])
await db.flush()
await db.refresh(account)
await log_audit(db, tenant_id, user_id, "update", "bank_account", account_id, changes=changes)
return _account_to_dict(account)
async def delete_bank_account(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
account_id: uuid.UUID,
) -> bool:
"""Soft-delete a bank account."""
q = select(BankAccount).where(
BankAccount.id == account_id,
BankAccount.tenant_id == tenant_id,
BankAccount.deleted_at.is_(None),
)
result = await db.execute(q)
account = result.scalar_one_or_none()
if account is None:
return False
account.deleted_at = datetime.now(UTC)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "bank_account", account_id,
changes={"bank_name": account.bank_name},
)
return True
+191 -28
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingsFirmendatenPage } from './SettingsFirmendaten';
import { SettingsCurrenciesPage } from './SettingsCurrencies';
@@ -10,6 +10,33 @@ import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Table, TableColumn } from '@/components/ui/Table';
import { Plus, Trash2, Pencil } from 'lucide-react';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
import { useAuthStore } from '@/store/authStore';
import { useToast } from '@/components/ui/Toast';
// --- Address type mapping (frontend German -> backend English) ---
const addressTypeToBackend: Record<string, string> = {
standort: 'headquarters',
rechnung: 'billing',
lieferung: 'shipping',
eigene: 'other',
};
const addressTypeToFrontend: Record<string, string> = {
headquarters: 'standort',
billing: 'rechnung',
shipping: 'lieferung',
branch: 'standort',
private: 'eigene',
other: 'eigene',
};
const addressTypeOptions = [
{ value: 'standort', label: 'Standort' },
{ value: 'rechnung', label: 'Rechnungsadresse' },
{ value: 'lieferung', label: 'Lieferadresse' },
{ value: 'eigene', label: 'Eigene Bezeichnung' },
];
// --- Adressen Tab ---
interface Address {
@@ -22,18 +49,54 @@ interface Address {
label: string;
}
const addressTypeOptions = [
{ value: 'standort', label: 'Standort' },
{ value: 'rechnung', label: 'Rechnungsadresse' },
{ value: 'lieferung', label: 'Lieferadresse' },
{ value: 'eigene', label: 'Eigene Bezeichnung' },
];
interface ApiAddress {
id: string;
address_type: string;
street: string;
zip: string;
city: string;
country: string;
label: string;
is_default: boolean;
}
function AdressenTab() {
const { t } = useTranslation();
const toast = useToast();
const currentTenant = useAuthStore((state) => state.currentTenant);
const [addresses, setAddresses] = useState<Address[]>([]);
const [editing, setEditing] = useState<Address | null>(null);
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(true);
const fetchAddresses = useCallback(async () => {
if (!currentTenant?.id) return;
try {
setLoading(true);
const data = await apiGet<{ items: ApiAddress[]; total: number }>(
`/addresses?entity_type=contact&entity_id=${currentTenant.id}`
);
setAddresses(
data.items.map((a) => ({
id: a.id,
type: addressTypeToFrontend[a.address_type] || 'eigene',
street: a.street || '',
zip: a.zip || '',
city: a.city || '',
country: a.country || 'DE',
label: a.label || '',
}))
);
} catch (err: any) {
toast.error(err.message || 'Fehler beim Laden der Adressen');
} finally {
setLoading(false);
}
}, [currentTenant?.id, toast]);
useEffect(() => {
fetchAddresses();
}, [fetchAddresses]);
const handleAdd = () => {
setEditing({ id: '', type: 'standort', street: '', zip: '', city: '', country: 'DE', label: '' });
@@ -45,19 +108,52 @@ function AdressenTab() {
setShowForm(true);
};
const handleDelete = (id: string) => {
setAddresses(addresses.filter(a => a.id !== id));
const handleDelete = async (id: string) => {
try {
await apiDelete(`/addresses/${id}`);
setAddresses(addresses.filter(a => a.id !== id));
toast.success('Adresse gelöscht');
} catch (err: any) {
toast.error(err.message || 'Fehler beim Löschen');
}
};
const handleSave = () => {
if (!editing) return;
if (editing.id) {
setAddresses(addresses.map(a => a.id === editing.id ? editing : a));
} else {
setAddresses([...addresses, { ...editing, id: Date.now().toString() }]);
const handleSave = async () => {
if (!editing || !currentTenant?.id) return;
try {
const backendType = addressTypeToBackend[editing.type] || 'other';
const payload = {
entity_type: 'contact',
entity_id: currentTenant.id,
label: editing.type === 'eigene' ? editing.label : addressTypeOptions.find(o => o.value === editing.type)?.label || editing.label,
address_type: backendType,
street: editing.street,
zip: editing.zip,
city: editing.city,
country: editing.country,
is_default: false,
};
if (editing.id) {
await apiPatch(`/addresses/${editing.id}`, {
label: payload.label,
address_type: payload.address_type,
street: payload.street,
zip: payload.zip,
city: payload.city,
country: payload.country,
});
toast.success('Adresse aktualisiert');
} else {
await apiPost('/addresses', payload);
toast.success('Adresse angelegt');
}
setShowForm(false);
setEditing(null);
await fetchAddresses();
} catch (err: any) {
toast.error(err.message || 'Fehler beim Speichern');
}
setShowForm(false);
setEditing(null);
};
const columns: TableColumn<Address>[] = [
@@ -89,7 +185,11 @@ function AdressenTab() {
{t('common.add', 'Hinzufügen')}
</Button>
}>
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
{loading ? (
<div className="text-center py-4 text-secondary-500">Lade Adressen...</div>
) : (
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
)}
</Card>
{showForm && editing && (
@@ -152,11 +252,48 @@ interface BankAccount {
defaultTax: string;
}
interface ApiBankAccount {
id: string;
bank_name: string;
iban: string;
bic: string | null;
account_holder: string | null;
default_tax: string | null;
is_default: boolean;
}
function KontenTab() {
const { t } = useTranslation();
const toast = useToast();
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null);
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(true);
const fetchAccounts = useCallback(async () => {
try {
setLoading(true);
const data = await apiGet<{ items: ApiBankAccount[]; total: number }>('/bank-accounts');
setAccounts(
data.items.map((a) => ({
id: a.id,
bankName: a.bank_name,
iban: a.iban,
bic: a.bic || '',
accountHolder: a.account_holder || '',
defaultTax: a.default_tax || '',
}))
);
} catch (err: any) {
toast.error(err.message || 'Fehler beim Laden der Konten');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
fetchAccounts();
}, [fetchAccounts]);
const handleAdd = () => {
setEditing({ id: '', bankName: '', iban: '', bic: '', accountHolder: '', defaultTax: '' });
@@ -168,19 +305,41 @@ function KontenTab() {
setShowForm(true);
};
const handleDelete = (id: string) => {
setAccounts(accounts.filter(a => a.id !== id));
const handleDelete = async (id: string) => {
try {
await apiDelete(`/bank-accounts/${id}`);
setAccounts(accounts.filter(a => a.id !== id));
toast.success('Konto gelöscht');
} catch (err: any) {
toast.error(err.message || 'Fehler beim Löschen');
}
};
const handleSave = () => {
const handleSave = async () => {
if (!editing) return;
if (editing.id) {
setAccounts(accounts.map(a => a.id === editing.id ? editing : a));
} else {
setAccounts([...accounts, { ...editing, id: Date.now().toString() }]);
try {
const payload = {
bank_name: editing.bankName,
iban: editing.iban,
bic: editing.bic || null,
account_holder: editing.accountHolder || null,
default_tax: editing.defaultTax || null,
is_default: false,
};
if (editing.id) {
await apiPatch(`/bank-accounts/${editing.id}`, payload);
toast.success('Konto aktualisiert');
} else {
await apiPost('/bank-accounts', payload);
toast.success('Konto angelegt');
}
setShowForm(false);
setEditing(null);
await fetchAccounts();
} catch (err: any) {
toast.error(err.message || 'Fehler beim Speichern');
}
setShowForm(false);
setEditing(null);
};
const columns: TableColumn<BankAccount>[] = [
@@ -211,7 +370,11 @@ function KontenTab() {
{t('common.add', 'Hinzufügen')}
</Button>
}>
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
{loading ? (
<div className="text-center py-4 text-secondary-500">Lade Konten...</div>
) : (
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
)}
</Card>
{showForm && editing && (