diff --git a/alembic/versions/0033_bank_accounts.py b/alembic/versions/0033_bank_accounts.py new file mode 100644 index 0000000..eb0a22b --- /dev/null +++ b/alembic/versions/0033_bank_accounts.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py index fac51cc..a97af4f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/bank_account.py b/app/models/bank_account.py new file mode 100644 index 0000000..c894ea8 --- /dev/null +++ b/app/models/bank_account.py @@ -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) diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py index 55b1bc3..3354ead 100644 --- a/app/plugins/builtins/kommunikation/plugin.py +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -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.""" diff --git a/app/routes/__init__.py b/app/routes/__init__.py index d97b8ac..26f34fe 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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 diff --git a/app/routes/bank_accounts.py b/app/routes/bank_accounts.py new file mode 100644 index 0000000..b3abe23 --- /dev/null +++ b/app/routes/bank_accounts.py @@ -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"}) diff --git a/app/schemas/bank_account.py b/app/schemas/bank_account.py new file mode 100644 index 0000000..52cb7dc --- /dev/null +++ b/app/schemas/bank_account.py @@ -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 diff --git a/app/services/bank_account_service.py b/app/services/bank_account_service.py new file mode 100644 index 0000000..dfc1878 --- /dev/null +++ b/app/services/bank_account_service.py @@ -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 diff --git a/frontend/src/pages/SettingsStammdaten.tsx b/frontend/src/pages/SettingsStammdaten.tsx index b038c9f..70afb43 100644 --- a/frontend/src/pages/SettingsStammdaten.tsx +++ b/frontend/src/pages/SettingsStammdaten.tsx @@ -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 = { + standort: 'headquarters', + rechnung: 'billing', + lieferung: 'shipping', + eigene: 'other', +}; + +const addressTypeToFrontend: Record = { + 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([]); const [editing, setEditing] = useState
(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
[] = [ @@ -89,7 +185,11 @@ function AdressenTab() { {t('common.add', 'Hinzufügen')} }> - row.id} emptyMessage="Noch keine Adressen angelegt" /> + {loading ? ( +
Lade Adressen...
+ ) : ( +
row.id} emptyMessage="Noch keine Adressen angelegt" /> + )} {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([]); const [editing, setEditing] = useState(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[] = [ @@ -211,7 +370,11 @@ function KontenTab() { {t('common.add', 'Hinzufügen')} }> -
row.id} emptyMessage="Noch keine Konten angelegt" /> + {loading ? ( +
Lade Konten...
+ ) : ( +
row.id} emptyMessage="Noch keine Konten angelegt" /> + )} {showForm && editing && (