478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
import { asError } from '@/utils/errorTypes';
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { SettingsFirmendatenPage } from './SettingsFirmendaten';
|
|
import { SettingsCurrenciesPage } from './SettingsCurrencies';
|
|
import { SettingsTaxesPage } from './SettingsTaxes';
|
|
import { SettingsSequencesPage } from './SettingsSequences';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Input } from '@/components/ui/Input';
|
|
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 {
|
|
id: string;
|
|
type: string;
|
|
street: string;
|
|
zip: string;
|
|
city: string;
|
|
country: string;
|
|
label: string;
|
|
}
|
|
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.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: '' });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleEdit = (addr: Address) => {
|
|
setEditing({ ...addr });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await apiDelete(`/addresses/${id}`);
|
|
setAddresses(addresses.filter(a => a.id !== id));
|
|
toast.success('Adresse gelöscht');
|
|
} catch (err: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Löschen');
|
|
}
|
|
};
|
|
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Speichern');
|
|
}
|
|
};
|
|
|
|
const columns: TableColumn<Address>[] = [
|
|
{ key: 'type', header: 'Art', render: (row) => addressTypeOptions.find(o => o.value === row.type)?.label || row.type },
|
|
{ key: 'street', header: t('address.street', 'Straße') },
|
|
{ key: 'zip', header: t('address.zip', 'PLZ') },
|
|
{ key: 'city', header: t('address.city', 'Stadt') },
|
|
{ key: 'country', header: t('address.country', 'Land') },
|
|
{ key: 'label', header: 'Bezeichnung', render: (row) => row.label || '—' },
|
|
{
|
|
key: 'actions', header: '', render: (row) => (
|
|
<div className="flex gap-2">
|
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card title="Adressverwaltung" actions={
|
|
<Button size="sm" onClick={handleAdd}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('common.add', 'Hinzufügen')}
|
|
</Button>
|
|
}>
|
|
{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 && (
|
|
<Card title={editing.id ? 'Adresse bearbeiten' : 'Neue Adresse'}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Select
|
|
label="Art"
|
|
options={addressTypeOptions}
|
|
value={editing.type}
|
|
onChange={(e) => setEditing({ ...editing, type: e.target.value })}
|
|
/>
|
|
{editing.type === 'eigene' && (
|
|
<Input
|
|
label="Eigene Bezeichnung"
|
|
value={editing.label}
|
|
onChange={(e) => setEditing({ ...editing, label: e.target.value })}
|
|
/>
|
|
)}
|
|
<Input
|
|
label={t('address.street', 'Straße')}
|
|
value={editing.street}
|
|
onChange={(e) => setEditing({ ...editing, street: e.target.value })}
|
|
/>
|
|
<Input
|
|
label={t('address.zip', 'PLZ')}
|
|
value={editing.zip}
|
|
onChange={(e) => setEditing({ ...editing, zip: e.target.value })}
|
|
/>
|
|
<Input
|
|
label={t('address.city', 'Stadt')}
|
|
value={editing.city}
|
|
onChange={(e) => setEditing({ ...editing, city: e.target.value })}
|
|
/>
|
|
<Input
|
|
label={t('address.country', 'Land')}
|
|
value={editing.country}
|
|
maxLength={2}
|
|
onChange={(e) => setEditing({ ...editing, country: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-4">
|
|
<Button variant="secondary" onClick={() => { setShowForm(false); setEditing(null); }}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button onClick={handleSave}>{t('common.save', 'Speichern')}</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Konten Tab ---
|
|
interface BankAccount {
|
|
id: string;
|
|
bankName: string;
|
|
iban: string;
|
|
bic: string;
|
|
accountHolder: string;
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Laden der Konten');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [toast]);
|
|
|
|
useEffect(() => {
|
|
fetchAccounts();
|
|
}, [fetchAccounts]);
|
|
|
|
const handleAdd = () => {
|
|
setEditing({ id: '', bankName: '', iban: '', bic: '', accountHolder: '', defaultTax: '' });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleEdit = (acc: BankAccount) => {
|
|
setEditing({ ...acc });
|
|
setShowForm(true);
|
|
};
|
|
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Löschen');
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!editing) return;
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.message || 'Fehler beim Speichern');
|
|
}
|
|
};
|
|
|
|
const columns: TableColumn<BankAccount>[] = [
|
|
{ key: 'bankName', header: 'Bankname' },
|
|
{ key: 'iban', header: 'IBAN' },
|
|
{ key: 'bic', header: 'BIC' },
|
|
{ key: 'accountHolder', header: 'Kontoinhaber' },
|
|
{ key: 'defaultTax', header: 'Standard-Steuer', render: (row) => row.defaultTax || '—' },
|
|
{
|
|
key: 'actions', header: '', render: (row) => (
|
|
<div className="flex gap-2">
|
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card title="Bankkontenverwaltung" actions={
|
|
<Button size="sm" onClick={handleAdd}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('common.add', 'Hinzufügen')}
|
|
</Button>
|
|
}>
|
|
{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 && (
|
|
<Card title={editing.id ? 'Konto bearbeiten' : 'Neues Konto'}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Input
|
|
label="Bankname"
|
|
value={editing.bankName}
|
|
onChange={(e) => setEditing({ ...editing, bankName: e.target.value })}
|
|
/>
|
|
<Input
|
|
label="IBAN"
|
|
value={editing.iban}
|
|
onChange={(e) => setEditing({ ...editing, iban: e.target.value })}
|
|
/>
|
|
<Input
|
|
label="BIC"
|
|
value={editing.bic}
|
|
onChange={(e) => setEditing({ ...editing, bic: e.target.value })}
|
|
/>
|
|
<Input
|
|
label="Kontoinhaber"
|
|
value={editing.accountHolder}
|
|
onChange={(e) => setEditing({ ...editing, accountHolder: e.target.value })}
|
|
/>
|
|
<Input
|
|
label="Standard-Steuer"
|
|
value={editing.defaultTax}
|
|
onChange={(e) => setEditing({ ...editing, defaultTax: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-4">
|
|
<Button variant="secondary" onClick={() => { setShowForm(false); setEditing(null); }}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button onClick={handleSave}>{t('common.save', 'Speichern')}</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Sonstige Daten Tab ---
|
|
function SonstigeDatenTab() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<SettingsCurrenciesPage />
|
|
<SettingsTaxesPage />
|
|
<SettingsSequencesPage />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Main Stammdaten Page ---
|
|
export function SettingsStammdatenPage() {
|
|
const { t } = useTranslation();
|
|
const [activeTab, setActiveTab] = useState('firmendaten');
|
|
|
|
const tabs = [
|
|
{ key: 'firmendaten', label: 'Firmendaten' },
|
|
{ key: 'adressen', label: 'Adressen' },
|
|
{ key: 'konten', label: 'Konten' },
|
|
{ key: 'sonstige', label: 'Sonstige Daten' },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6" data-testid="settings-stammdaten-page">
|
|
<h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
|
|
|
|
<div className="border-b border-secondary-200">
|
|
<nav className="flex gap-4" role="tablist" aria-label="Stammdaten Tabs">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
role="tab"
|
|
aria-selected={activeTab === tab.key}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.key
|
|
? 'border-primary-500 text-primary-700'
|
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
<div role="tabpanel">
|
|
{activeTab === 'firmendaten' && <SettingsFirmendatenPage />}
|
|
{activeTab === 'adressen' && <AdressenTab />}
|
|
{activeTab === 'konten' && <KontenTab />}
|
|
{activeTab === 'sonstige' && <SonstigeDatenTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|