feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste

- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System
- Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten
- Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde
- Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:50:06 +02:00
parent 1f1c984184
commit 450218fe77
26 changed files with 3943 additions and 36 deletions
@@ -0,0 +1,210 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type DomainsCustomer, DomainsRequestError } from '@/lib/domains-api';
import de from '@/messages/de.json';
import { ContactForm, EMPTY_CONTACT, toCreateInput, validateContact } from './ContactForm';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockCreateContact = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
return { ...actual, createContact: (...args: unknown[]) => mockCreateContact(...args) };
});
const CUSTOMERS: DomainsCustomer[] = [
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 0 },
];
const VALID = {
...EMPTY_CONTACT,
fname: 'Erika',
lname: 'Muster',
street: 'Musterstraße 1',
pcode: '12345',
city: 'Berlin',
email: 'erika@example.com',
phone: '+49 30 123456',
};
describe('validateContact', () => {
it('Person: alle Pflichtfelder, Organisation nicht', () => {
expect(Object.keys(validateContact(EMPTY_CONTACT)).sort()).toEqual(
['city', 'email', 'fname', 'lname', 'pcode', 'phone', 'street'].sort(),
);
expect(validateContact(VALID)).toEqual({});
});
it('Organisation verlangt zusaetzlich die Organisation', () => {
expect(validateContact({ ...VALID, type: 'ORG' })).toEqual({ organization: 'required' });
expect(validateContact({ ...VALID, type: 'ORG', organization: 'Muster GmbH' })).toEqual({});
});
it('Telefon muss mit + beginnen, die E-Mail muss gueltig sein', () => {
expect(validateContact({ ...VALID, phone: '030 123456' })).toEqual({ phone: 'phone' });
expect(validateContact({ ...VALID, email: 'keine-adresse' })).toEqual({ email: 'email' });
expect(validateContact({ ...VALID, phone: '+49' })).toEqual({ phone: 'phone' });
});
});
describe('toCreateInput', () => {
it('schneidet Leerraum ab, laesst leere Zeilen weg, sendet den Kunden nur wenn gewaehlt', () => {
const person = toCreateInput({
...VALID,
fname: ' Erika ',
street2: ' ',
phone: ' +49 30 123456 ',
organization: 'wird bei Person ignoriert',
});
expect(person).toEqual({
type: 'PERSON',
fname: 'Erika',
lname: 'Muster',
street: ['Musterstraße 1'],
pcode: '12345',
city: 'Berlin',
country: 'DE',
email: 'erika@example.com',
phone: '+49 30 123456',
});
expect(
toCreateInput({
...VALID,
type: 'ORG',
organization: ' Muster GmbH ',
street2: 'Hof',
customerId: 'c-a',
}),
).toMatchObject({
type: 'ORG',
organization: 'Muster GmbH',
street: ['Musterstraße 1', 'Hof'],
customerId: 'c-a',
});
});
});
function fill(id: string, value: string) {
fireEvent.change(document.getElementById(id) as HTMLInputElement, { target: { value } });
}
function fillValid() {
fill('contact-fname', 'Erika');
fill('contact-lname', 'Muster');
fill('contact-street', 'Musterstraße 1');
fill('contact-pcode', '12345');
fill('contact-city', 'Berlin');
fill('contact-phone', '+49 30 123456');
fill('contact-email', 'erika@example.com');
}
beforeEach(() => {
mockCreateContact.mockReset();
});
afterEach(() => cleanup());
describe('ContactForm', () => {
it('zeigt Pflichtfehler und sendet nichts, solange etwas fehlt', () => {
const onCreated = vi.fn();
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(mockCreateContact).not.toHaveBeenCalled();
expect(screen.getAllByText('Bitte füllen Sie dieses Feld aus.').length).toBeGreaterThan(3);
expect(screen.getByText('Internationale Schreibweise, z. B. +49 30 123456')).toBeTruthy();
});
it('ungueltige E-Mail blockiert das Absenden', () => {
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
fillValid();
fill('contact-email', 'ohne-at');
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(mockCreateContact).not.toHaveBeenCalled();
expect(screen.getByText('Bitte geben Sie eine gültige E-Mail-Adresse an.')).toBeTruthy();
});
it('Organisation ist erst bei "Firma oder Organisation" ein Feld und Pflicht', () => {
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
expect(document.getElementById('contact-organization')).toBeNull();
fireEvent.click(screen.getByRole('radio', { name: 'Firma oder Organisation' }));
expect(document.getElementById('contact-organization')).not.toBeNull();
fillValid();
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(mockCreateContact).not.toHaveBeenCalled();
});
it('ruft createContact einmal mit den getrimmten Werten und dem Kunden auf', async () => {
const onCreated = vi.fn();
mockCreateContact.mockResolvedValue({ id: 4711 });
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
fillValid();
fill('contact-fname', ' Erika ');
fireEvent.change(document.getElementById('contact-customer') as HTMLSelectElement, {
target: { value: 'c-a' },
});
const submit = screen.getByRole('button', { name: 'Speichern' });
fireEvent.click(submit);
fireEvent.click(submit);
await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1));
expect(mockCreateContact).toHaveBeenCalledTimes(1);
expect(mockCreateContact).toHaveBeenCalledWith({
type: 'PERSON',
fname: 'Erika',
lname: 'Muster',
street: ['Musterstraße 1'],
pcode: '12345',
city: 'Berlin',
country: 'DE',
email: 'erika@example.com',
phone: '+49 30 123456',
customerId: 'c-a',
});
});
it('sperrt den Knopf waehrend des Speicherns', async () => {
let resolve: (v: unknown) => void = () => undefined;
mockCreateContact.mockImplementation(
() =>
new Promise((r) => {
resolve = r;
}),
);
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
fillValid();
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
const busy = await screen.findByRole('button', { name: 'Speichern …' });
expect((busy as HTMLButtonElement).disabled).toBe(true);
resolve({ id: 1 });
});
it('zeigt den Fehlertext der API im Formular', async () => {
mockCreateContact.mockRejectedValue(
new DomainsRequestError(502, 'autodnsError', 'AutoDNS meldet: Ungültige Postleitzahl.'),
);
const onCreated = vi.fn();
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
fillValid();
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('AutoDNS meldet: Ungültige Postleitzahl.')).toBeTruthy();
expect(onCreated).not.toHaveBeenCalled();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
false,
);
});
it('Land: Deutschland ist vorgewaehlt, Namen kommen aus der Sprache', () => {
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
const select = document.getElementById('contact-country') as HTMLSelectElement;
expect(select.value).toBe('DE');
const labels = Array.from(select.options).map((o) => o.textContent);
expect(labels).toContain('Deutschland');
expect(labels).toContain('Österreich');
});
});
@@ -0,0 +1,317 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { type FormEvent, useMemo, useRef, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
import {
type CreateDomainsContactInput,
createContact,
type DomainsContact,
type DomainsContactType,
type DomainsCustomer,
DomainsRequestError,
} from '@/lib/domains-api';
/** Laender zur Auswahl: DACH, alle EU-Staaten, GB, NO, US. */
export const CONTACT_COUNTRIES = [
'DE',
'AT',
'CH',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
'GB',
'NO',
'US',
] as const;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Gleiche Regel wie in der API (`CreateDomainsContactDto`). */
const PHONE_PATTERN = /^\+[0-9][0-9 .\-/]{5,30}$/;
export interface ContactFormValues {
type: DomainsContactType;
organization: string;
fname: string;
lname: string;
street: string;
street2: string;
pcode: string;
city: string;
country: string;
phone: string;
email: string;
customerId: string;
}
export type ContactFormField = keyof ContactFormValues;
export type ContactFormErrors = Partial<Record<ContactFormField, 'required' | 'email' | 'phone'>>;
export const EMPTY_CONTACT: ContactFormValues = {
type: 'PERSON',
organization: '',
fname: '',
lname: '',
street: '',
street2: '',
pcode: '',
city: '',
country: 'DE',
phone: '',
email: '',
customerId: '',
};
/** Reine Pruefung (ohne React), damit sie einzeln testbar bleibt. */
export function validateContact(values: ContactFormValues): ContactFormErrors {
const errors: ContactFormErrors = {};
const required: ContactFormField[] = [
'fname',
'lname',
'street',
'pcode',
'city',
'country',
'email',
'phone',
];
if (values.type === 'ORG') required.push('organization');
for (const field of required) {
if (values[field].trim() === '') errors[field] = 'required';
}
if (!errors.email && !EMAIL_PATTERN.test(values.email.trim())) errors.email = 'email';
if (!errors.phone && !PHONE_PATTERN.test(values.phone.trim().replace(/\s+/g, ' '))) {
errors.phone = 'phone';
}
return errors;
}
export function toCreateInput(values: ContactFormValues): CreateDomainsContactInput {
const street = [values.street.trim(), values.street2.trim()].filter((line) => line !== '');
return {
type: values.type,
...(values.type === 'ORG' ? { organization: values.organization.trim() } : {}),
fname: values.fname.trim(),
lname: values.lname.trim(),
street,
pcode: values.pcode.trim(),
city: values.city.trim(),
country: values.country,
email: values.email.trim(),
phone: values.phone.trim().replace(/\s+/g, ' '),
...(values.customerId ? { customerId: values.customerId } : {}),
};
}
/**
* Formular "Neuer Kontakt" (L-03): legt einen Person- oder Organisations-Kontakt
* bei AutoDNS an und ordnet ihn optional gleich einem Kunden zu. Der Kunde
* geht nie an AutoDNS — er wird nur in Tessera gespeichert.
*/
export function ContactForm({
customers,
onCreated,
onCancel,
}: {
customers: DomainsCustomer[];
onCreated: (contact: DomainsContact) => void;
onCancel: () => void;
}) {
const t = useTranslations('domains.contactForm');
const tc = useTranslations('domains.common');
const locale = useLocale();
const [values, setValues] = useState<ContactFormValues>(EMPTY_CONTACT);
const [errors, setErrors] = useState<ContactFormErrors>({});
const [saving, setSaving] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
// Sperre gegen einen zweiten Aufruf noch vor dem naechsten Render.
const submitting = useRef(false);
const countries = useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'region' });
return CONTACT_COUNTRIES.map((code) => ({ code, label: names.of(code) ?? code })).sort((a, b) =>
a.label.localeCompare(b.label, locale),
);
}, [locale]);
const set = <K extends ContactFormField>(field: K, value: ContactFormValues[K]) => {
setValues((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
if (!prev[field]) return prev;
const { [field]: _removed, ...rest } = prev;
return rest;
});
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (submitting.current) return;
const found = validateContact(values);
setErrors(found);
if (Object.keys(found).length > 0) return;
submitting.current = true;
setSaving(true);
setApiError(null);
try {
const created = await createContact(toCreateInput(values));
onCreated(created);
} catch (e) {
setApiError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
submitting.current = false;
setSaving(false);
}
};
const errorText = (field: ContactFormField) => {
const kind = errors[field];
return kind ? t(`errors.${kind}`) : null;
};
const field = (
name: ContactFormField,
label: string,
opts: { type?: string; autoComplete?: string; hint?: string; optional?: boolean } = {},
) => (
<div>
<label htmlFor={`contact-${name}`} className="mb-1 block text-xs text-muted-foreground">
{label}
</label>
<input
id={`contact-${name}`}
type={opts.type ?? 'text'}
autoComplete={opts.autoComplete ?? 'off'}
className={INPUT_CLASS}
value={values[name]}
aria-invalid={errors[name] ? true : undefined}
aria-describedby={errors[name] ? `contact-${name}-error` : undefined}
onChange={(e) => set(name, e.target.value as ContactFormValues[typeof name])}
/>
{opts.hint && <p className="mt-1 text-xs text-muted-foreground">{opts.hint}</p>}
{errors[name] && (
<p id={`contact-${name}-error`} className="mt-1 text-xs text-destructive">
{errorText(name)}
</p>
)}
</div>
);
return (
<form onSubmit={submit} noValidate>
<SettingsSection
id="domains-contact-form"
title={t('title')}
footer={
<>
{apiError && (
<p role="alert" className="text-sm text-destructive">
{apiError}
</p>
)}
<button type="button" className={SECONDARY_BUTTON} disabled={saving} onClick={onCancel}>
{t('cancel')}
</button>
<button type="submit" className={PRIMARY_BUTTON} disabled={saving}>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="space-y-4">
<fieldset className="flex flex-wrap gap-6">
<legend className="mb-1 text-xs text-muted-foreground">{t('type')}</legend>
{(['PERSON', 'ORG'] as const).map((type) => (
<label key={type} className="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="contact-type"
value={type}
checked={values.type === type}
onChange={() => set('type', type)}
/>
{type === 'PERSON' ? t('person') : t('org')}
</label>
))}
</fieldset>
{values.type === 'ORG' && field('organization', t('organization'))}
<div className="grid gap-4 sm:grid-cols-2">
{field('fname', t('fname'), { autoComplete: 'given-name' })}
{field('lname', t('lname'), { autoComplete: 'family-name' })}
</div>
{field('street', t('street'), { autoComplete: 'address-line1' })}
{field('street2', t('street2'), { autoComplete: 'address-line2' })}
<div className="grid gap-4 sm:grid-cols-3">
{field('pcode', t('pcode'), { autoComplete: 'postal-code' })}
<div className="sm:col-span-2">
{field('city', t('city'), { autoComplete: 'address-level2' })}
</div>
</div>
<div>
<label htmlFor="contact-country" className="mb-1 block text-xs text-muted-foreground">
{t('country')}
</label>
<select
id="contact-country"
className={INPUT_CLASS}
value={values.country}
onChange={(e) => set('country', e.target.value)}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.label}
</option>
))}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{field('phone', t('phone'), { type: 'tel', autoComplete: 'tel', hint: t('phoneHint') })}
{field('email', t('email'), { type: 'email', autoComplete: 'email' })}
</div>
<div>
<label htmlFor="contact-customer" className="mb-1 block text-xs text-muted-foreground">
{t('customer')}
</label>
<select
id="contact-customer"
className={INPUT_CLASS}
value={values.customerId}
onChange={(e) => set('customerId', e.target.value)}
>
<option value="">{t('noCustomer')}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
</SettingsSection>
</form>
);
}
@@ -0,0 +1,340 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
ALL_CUSTOMERS,
filterByCustomer,
groupByCustomer,
matchesText,
UNASSIGNED,
} from '@/components/domains/group-by-customer';
import {
INPUT_CLASS,
PRIMARY_BUTTON,
SECONDARY_BUTTON,
TABLE_CELL,
TABLE_HEAD_CELL,
TABLE_HEAD_CLASS,
} from '@/components/domains/ui-classes';
import {
assignContacts,
type DomainsContact,
type DomainsContactList,
type DomainsCustomer,
DomainsRequestError,
listContacts,
} from '@/lib/domains-api';
import { ContactForm } from './ContactForm';
/** Wert im Zuordnungs-Auswahlfeld fuer "Zuordnung entfernen". */
const REMOVE_ASSIGNMENT = '__remove__';
function ContactTable({
rows,
canManage,
selected,
onToggle,
}: {
rows: DomainsContact[];
canManage: boolean;
selected: Set<number>;
onToggle: (id: number) => void;
}) {
const t = useTranslations('domains.contactList');
const tc = useTranslations('domains.common');
return (
<table className="w-full text-left text-sm">
<thead className={TABLE_HEAD_CLASS}>
<tr>
{canManage && <th className={`${TABLE_HEAD_CELL} w-10`} />}
<th className={TABLE_HEAD_CELL}>{t('columns.name')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.organization')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.city')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.email')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.customer')}</th>
</tr>
</thead>
<tbody>
{rows.map((contact) => {
const person = [contact.fname, contact.lname].filter(Boolean).join(' ');
return (
<tr key={contact.id} className="border-t border-border">
{canManage && (
<td className={TABLE_CELL}>
<input
type="checkbox"
aria-label={t('selectContact', { name: contact.displayName })}
checked={selected.has(contact.id)}
onChange={() => onToggle(contact.id)}
/>
</td>
)}
<td className={`${TABLE_CELL} font-medium text-foreground`}>
{person || contact.displayName}
</td>
<td className={TABLE_CELL}>{contact.organization ?? '–'}</td>
<td className={TABLE_CELL}>{contact.city ?? '–'}</td>
<td className={TABLE_CELL}>{contact.email ?? '–'}</td>
<td className={TABLE_CELL}>{contact.customerName ?? tc('unassigned')}</td>
</tr>
);
})}
</tbody>
</table>
);
}
/**
* Reiter "Kontakte" (L-03, D-G): die Kontakte des aktiven AutoDNS-Systems,
* live gelesen. Bereits vorhandene AutoDNS-Kontakte erscheinen hier von selbst
* und lassen sich einem Kunden zuordnen. Anlegen und Zuordnen nur mit
* "Verwalten"; bindend ist allein die API.
*/
export function ContactsTab({
customers,
canManage,
onCustomersChanged,
}: {
customers: DomainsCustomer[];
canManage: boolean;
onCustomersChanged: () => void;
}) {
const t = useTranslations('domains.contactList');
const tc = useTranslations('domains.common');
const [data, setData] = useState<DomainsContactList | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<string>(ALL_CUSTOMERS);
const [grouped, setGrouped] = useState(true);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [target, setTarget] = useState('');
const [assigning, setAssigning] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const load = useCallback(
async (refresh: boolean) => {
setLoading(true);
setError(null);
try {
setData(await listContacts({ refresh }));
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setLoading(false);
}
},
[tc],
);
useEffect(() => {
void load(false);
}, [load]);
const visible = useMemo(() => {
if (!data) return [];
return filterByCustomer(data.contacts, filter).filter((c) =>
matchesText(c, search, [
(x) => x.displayName,
(x) => x.organization,
(x) => x.city,
(x) => x.email,
(x) => x.customerName,
]),
);
}, [data, filter, search]);
const groups = useMemo(
() => (grouped ? groupByCustomer(visible, customers) : []),
[grouped, visible, customers],
);
const toggle = (id: number) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const allVisibleSelected = visible.length > 0 && visible.every((c) => selected.has(c.id));
const toggleAllVisible = () =>
setSelected(allVisibleSelected ? new Set() : new Set(visible.map((c) => c.id)));
const assign = async () => {
if (selected.size === 0 || !target) return;
setAssigning(true);
setNotice(null);
setError(null);
try {
await assignContacts([...selected], target === REMOVE_ASSIGNMENT ? null : target);
setSelected(new Set());
setTarget('');
setNotice(t('assigned'));
await load(false);
onCustomersChanged();
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setAssigning(false);
}
};
const created = async () => {
setCreating(false);
setNotice(t('created'));
await load(false);
onCustomersChanged();
};
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('intro')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-48 flex-1">
<label htmlFor="contacts-search" className="mb-1 block text-xs text-muted-foreground">
{t('search')}
</label>
<input
id="contacts-search"
type="search"
className={INPUT_CLASS}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label htmlFor="contacts-filter" className="mb-1 block text-xs text-muted-foreground">
{tc('customerFilter')}
</label>
<select
id="contacts-filter"
className={INPUT_CLASS}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value={ALL_CUSTOMERS}>{tc('allCustomers')}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value={UNASSIGNED}>{tc('unassigned')}</option>
</select>
</div>
<label className="flex items-center gap-2 pb-2 text-sm text-foreground">
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
{tc('groupByCustomer')}
</label>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={loading}
onClick={() => void load(true)}
>
{t('refresh')}
</button>
{canManage && !creating && (
<button type="button" className={PRIMARY_BUTTON} onClick={() => setCreating(true)}>
{t('newContact')}
</button>
)}
</div>
{canManage && creating && (
<ContactForm
customers={customers}
onCreated={() => void created()}
onCancel={() => setCreating(false)}
/>
)}
{canManage && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} />
{t('selectAllVisible')}
</label>
<span className="text-sm text-muted-foreground">
{t('selectedCount', { count: selected.size })}
</span>
<label htmlFor="contacts-assign-target" className="text-sm font-medium text-foreground">
{t('assignLabel')}
</label>
<select
id="contacts-assign-target"
className={`${INPUT_CLASS} w-auto`}
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">{t('assignPlaceholder')}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value={REMOVE_ASSIGNMENT}>{t('removeAssignment')}</option>
</select>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={assigning || selected.size === 0 || !target}
onClick={() => void assign()}
>
{t('assign')}
</button>
</div>
)}
{notice && (
<p role="status" className="text-sm text-status-ok-fg">
{notice}
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{loading && !data && <p className="text-sm text-muted-foreground">{tc('loading')}</p>}
{data?.truncated && <p className="text-sm text-muted-foreground">{tc('truncated')}</p>}
{data && visible.length === 0 && (
<p className="text-sm text-muted-foreground">
{data.contacts.length === 0 ? t('empty') : t('emptyFiltered')}
</p>
)}
{grouped
? groups.map((group) => (
<SettingsSection
key={group.key}
flush
title={group.name ?? tc('unassigned')}
description={t('groupCount', { count: group.items.length })}
>
<ContactTable
rows={group.items}
canManage={canManage}
selected={selected}
onToggle={toggle}
/>
</SettingsSection>
))
: visible.length > 0 && (
<SettingsSection flush description={t('groupCount', { count: visible.length })}>
<ContactTable
rows={visible}
canManage={canManage}
selected={selected}
onToggle={toggle}
/>
</SettingsSection>
)}
</div>
);
}
@@ -0,0 +1,263 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
import {
createCustomer,
type DomainsCustomer,
DomainsRequestError,
deleteCustomer,
updateCustomer,
} from '@/lib/domains-api';
/**
* Reiter "Kunden" (D-H): Kunden, denen AutoDNS-Kontakte zugeordnet werden.
* Die eigene Firma ist ein Kunde mit Markierung (hoechstens einer). Anlegen,
* Aendern und Loeschen nur mit "Verwalten"; ein Kunde mit Zuordnungen laesst
* sich nicht loeschen (die API antwortet dann 409).
*/
export function CustomersTab({
customers,
canManage,
onChanged,
}: {
customers: DomainsCustomer[];
canManage: boolean;
onChanged: () => void;
}) {
const t = useTranslations('domains.customers');
const tc = useTranslations('domains.common');
const [newName, setNewName] = useState('');
const [newOwn, setNewOwn] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editOwn, setEditOwn] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (action: () => Promise<unknown>, after?: () => void) => {
setBusy(true);
setError(null);
try {
await action();
after?.();
onChanged();
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setBusy(false);
}
};
const create = () =>
run(
() => createCustomer({ name: newName.trim(), isOwnCompany: newOwn }),
() => {
setNewName('');
setNewOwn(false);
},
);
const startEdit = (customer: DomainsCustomer) => {
setEditingId(customer.id);
setEditName(customer.name);
setEditOwn(customer.isOwnCompany);
setDeletingId(null);
setError(null);
};
const saveEdit = (id: string) =>
run(
() => updateCustomer(id, { name: editName.trim(), isOwnCompany: editOwn }),
() => setEditingId(null),
);
const remove = (id: string) =>
run(
() => deleteCustomer(id),
() => setDeletingId(null),
);
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('intro')}</p>
{canManage && (
<SettingsSection
id="domains-customer-new"
title={t('addTitle')}
footer={
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || newName.trim() === ''}
onClick={() => void create()}
>
{t('add')}
</button>
}
>
<div className="space-y-3">
<div>
<label
htmlFor="customer-new-name"
className="mb-1 block text-xs text-muted-foreground"
>
{t('name')}
</label>
<input
id="customer-new-name"
type="text"
maxLength={120}
className={INPUT_CLASS}
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={newOwn}
onChange={(e) => setNewOwn(e.target.checked)}
/>
{t('ownCompanyLabel')}
</label>
</div>
</SettingsSection>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{customers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('empty')}</p>
) : (
<SettingsSection flush>
<ul className="divide-y divide-border">
{customers.map((customer) => (
<li key={customer.id} className="space-y-3 px-4 py-3">
{editingId === customer.id ? (
<div className="space-y-3">
<div>
<label
htmlFor={`customer-edit-${customer.id}`}
className="mb-1 block text-xs text-muted-foreground"
>
{t('name')}
</label>
<input
id={`customer-edit-${customer.id}`}
type="text"
maxLength={120}
className={INPUT_CLASS}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={editOwn}
onChange={(e) => setEditOwn(e.target.checked)}
/>
{t('ownCompanyLabel')}
</label>
<div className="flex gap-2">
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || editName.trim() === ''}
onClick={() => void saveEdit(customer.id)}
>
{t('save')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy}
onClick={() => setEditingId(null)}
>
{t('cancel')}
</button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="font-medium text-foreground">{customer.name}</span>
{customer.isOwnCompany && (
<span className="inline-flex rounded-full bg-status-ok/12 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
{t('ownCompany')}
</span>
)}
<span className="text-sm text-muted-foreground">
{t('contactCount', { count: customer.contactCount })}
</span>
{canManage && (
<span className="ml-auto flex gap-2">
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy}
onClick={() => startEdit(customer)}
>
{t('edit')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy}
onClick={() => {
setDeletingId(customer.id);
setEditingId(null);
setError(null);
}}
>
{t('delete')}
</button>
</span>
)}
</div>
)}
{canManage && deletingId === customer.id && (
<div
role="alertdialog"
aria-label={t('confirmDelete', { name: customer.name })}
className="space-y-3 rounded-md border border-status-down/40 bg-status-down/10 p-4"
>
<p className="text-sm font-medium text-foreground">
{t('confirmDelete', { name: customer.name })}
</p>
<div className="flex gap-2">
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy}
onClick={() => void remove(customer.id)}
>
{t('confirmDeleteYes')}
</button>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy}
onClick={() => setDeletingId(null)}
>
{t('cancel')}
</button>
</div>
</div>
)}
</li>
))}
</ul>
</SettingsSection>
)}
</div>
);
}
@@ -0,0 +1,207 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
ALL_CUSTOMERS,
filterByCustomer,
groupByCustomer,
matchesText,
UNASSIGNED,
} from '@/components/domains/group-by-customer';
import {
INPUT_CLASS,
SECONDARY_BUTTON,
TABLE_CELL,
TABLE_HEAD_CELL,
TABLE_HEAD_CLASS,
} from '@/components/domains/ui-classes';
import {
type DomainsCustomer,
type DomainsDomain,
type DomainsDomainList,
DomainsRequestError,
listDomains,
} from '@/lib/domains-api';
/** Ablaufdatum als Datum (dd.mm.yyyy in Deutsch), unabhaengig von der Zeitzone des Browsers. */
function formatDate(iso: string | null, locale: string): string {
if (!iso) return '–';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '–';
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'Europe/Berlin',
}).format(date);
}
function DomainTable({ rows }: { rows: DomainsDomain[] }) {
const t = useTranslations('domains.domainList');
const locale = useLocale();
const statusLabel = (status: string | null) => {
if (!status) return '–';
return t.has(`status.${status}`) ? t(`status.${status}`) : status;
};
return (
<table className="w-full text-left text-sm">
<thead className={TABLE_HEAD_CLASS}>
<tr>
<th className={TABLE_HEAD_CELL}>{t('columns.domain')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.customer')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.owner')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.expires')}</th>
<th className={TABLE_HEAD_CELL}>{t('columns.status')}</th>
</tr>
</thead>
<tbody>
{rows.map((domain) => (
<tr key={domain.name} className="border-t border-border">
<td className={`${TABLE_CELL} font-medium text-foreground`}>{domain.name}</td>
<td className={TABLE_CELL}>{domain.customerName ?? t('unassigned')}</td>
<td className={TABLE_CELL}>{domain.ownerName ?? '–'}</td>
<td className={`${TABLE_CELL} tabular-nums`}>{formatDate(domain.expire, locale)}</td>
<td className={TABLE_CELL}>
{statusLabel(domain.status)}
{domain.cancelationPending && (
<span className="ml-2 inline-flex rounded-full bg-status-warn/12 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
{t('cancelPending')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}
/**
* Reiter "Domains" (L-05): alle Domains des aktiven AutoDNS-Systems mit Kunde
* (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status — filterbar und
* nach Kunde gruppierbar. Nur lesend; sichtbar fuer alle mit "Benutzen".
*/
export function DomainsTab({ customers }: { customers: DomainsCustomer[] }) {
const t = useTranslations('domains.domainList');
const tc = useTranslations('domains.common');
const [data, setData] = useState<DomainsDomainList | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<string>(ALL_CUSTOMERS);
const [grouped, setGrouped] = useState(true);
const load = useCallback(
async (refresh: boolean) => {
setLoading(true);
setError(null);
try {
setData(await listDomains({ refresh }));
} catch (e) {
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
} finally {
setLoading(false);
}
},
[tc],
);
useEffect(() => {
void load(false);
}, [load]);
const visible = useMemo(() => {
if (!data) return [];
return filterByCustomer(data.domains, filter).filter((d) =>
matchesText(d, search, [(x) => x.name, (x) => x.ownerName, (x) => x.customerName]),
);
}, [data, filter, search]);
const groups = useMemo(
() => (grouped ? groupByCustomer(visible, customers) : []),
[grouped, visible, customers],
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-48 flex-1">
<label htmlFor="domains-search" className="mb-1 block text-xs text-muted-foreground">
{t('search')}
</label>
<input
id="domains-search"
type="search"
className={INPUT_CLASS}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label htmlFor="domains-filter" className="mb-1 block text-xs text-muted-foreground">
{tc('customerFilter')}
</label>
<select
id="domains-filter"
className={INPUT_CLASS}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value={ALL_CUSTOMERS}>{tc('allCustomers')}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value={UNASSIGNED}>{tc('unassigned')}</option>
</select>
</div>
<label className="flex items-center gap-2 pb-2 text-sm text-foreground">
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
{tc('groupByCustomer')}
</label>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={loading}
onClick={() => void load(true)}
>
{t('refresh')}
</button>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{loading && !data && <p className="text-sm text-muted-foreground">{tc('loading')}</p>}
{data?.truncated && <p className="text-sm text-muted-foreground">{tc('truncated')}</p>}
{data && visible.length === 0 && (
<p className="text-sm text-muted-foreground">
{data.domains.length === 0 ? t('empty') : t('emptyFiltered')}
</p>
)}
{grouped
? groups.map((group) => (
<SettingsSection
key={group.key}
flush
title={group.name ?? tc('unassigned')}
description={t('groupCount', { count: group.items.length })}
>
<DomainTable rows={group.items} />
</SettingsSection>
))
: visible.length > 0 && (
<SettingsSection flush description={t('groupCount', { count: visible.length })}>
<DomainTable rows={visible} />
</SettingsSection>
)}
</div>
);
}
@@ -9,7 +9,13 @@ import {
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
import type {
DomainsContactList,
DomainsCustomer,
DomainsDomainList,
DomainsSettings,
DomainsStatus,
} from '@/lib/domains-api';
import de from '@/messages/de.json';
import DomainsPage from './page';
@@ -25,6 +31,12 @@ const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestConnection = vi.fn();
const mockListCustomers = vi.fn();
const mockListContacts = vi.fn();
const mockListDomains = vi.fn();
const mockAssignContacts = vi.fn();
const mockCreateCustomer = vi.fn();
const mockDeleteCustomer = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
@@ -34,6 +46,12 @@ vi.mock('@/lib/domains-api', async (importOriginal) => {
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
listCustomers: (...args: unknown[]) => mockListCustomers(...args),
listContacts: (...args: unknown[]) => mockListContacts(...args),
listDomains: (...args: unknown[]) => mockListDomains(...args),
assignContacts: (...args: unknown[]) => mockAssignContacts(...args),
createCustomer: (...args: unknown[]) => mockCreateCustomer(...args),
deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args),
};
});
@@ -64,8 +82,109 @@ function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
};
}
const CUSTOMERS: DomainsCustomer[] = [
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 },
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 2 },
];
function domainList(): DomainsDomainList {
return {
environment: 'DEMO',
truncated: false,
fetchedAt: '2026-10-08T10:00:00.000Z',
domains: [
{
name: 'frei.de',
expire: '2027-03-01T12:00:00.000Z',
status: 'ACTIVE',
cancelationPending: false,
ownerContactId: 5,
ownerName: 'Niemand',
customerId: null,
customerName: null,
},
{
name: 'alpha.de',
expire: '2027-07-15T12:00:00.000Z',
status: 'LOCK',
cancelationPending: true,
ownerContactId: 6,
ownerName: 'Alpha GmbH',
customerId: 'c-a',
customerName: 'Alpha GmbH',
},
{
name: 'eigen.de',
expire: null,
status: 'SONDERWERT',
cancelationPending: false,
ownerContactId: 7,
ownerName: 'Unsere Firma',
customerId: 'c-own',
customerName: 'Unsere Firma',
},
],
};
}
function contactList(): DomainsContactList {
return {
environment: 'DEMO',
truncated: false,
fetchedAt: '2026-10-08T10:00:00.000Z',
contacts: [
{
id: 6,
type: 'ORG',
displayName: 'Alpha GmbH',
fname: 'Anna',
lname: 'Alpha',
organization: 'Alpha GmbH',
address: ['Weg 1'],
pcode: '10115',
city: 'Berlin',
country: 'DE',
email: 'anna@alpha.example',
phone: '+49 30 1',
alias: null,
customerId: 'c-a',
customerName: 'Alpha GmbH',
},
{
id: 5,
type: 'PERSON',
displayName: 'Bert Beta',
fname: 'Bert',
lname: 'Beta',
organization: null,
address: [],
pcode: null,
city: 'Hamburg',
country: 'DE',
email: 'bert@beta.example',
phone: null,
alias: null,
customerId: null,
customerName: null,
},
],
};
}
/** Oeffnet den Reiter Einstellungen (Standardreiter ist Domains). */
async function renderSettings() {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
}
beforeEach(() => {
mockCanManage = true;
mockListCustomers.mockReset().mockResolvedValue(CUSTOMERS);
mockListDomains.mockReset().mockResolvedValue(domainList());
mockListContacts.mockReset().mockResolvedValue(contactList());
mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 });
mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]);
mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true });
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset().mockImplementation(async () => settings());
@@ -82,6 +201,9 @@ describe('Domains-Seite — Kopf und Reiter', () => {
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Domains' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
'Demo-System (Testbetrieb)',
);
@@ -129,7 +251,7 @@ describe('Domains-Seite — Kopf und Reiter', () => {
describe('Einstellungen', () => {
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
render(<DomainsPage />);
await renderSettings();
const demoPassword = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
@@ -140,7 +262,7 @@ describe('Einstellungen', () => {
});
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
render(<DomainsPage />);
await renderSettings();
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
@@ -148,7 +270,7 @@ describe('Einstellungen', () => {
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
render(<DomainsPage />);
await renderSettings();
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
fireEvent.click(liveRadio);
@@ -176,7 +298,7 @@ describe('Einstellungen', () => {
});
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
render(<DomainsPage />);
await renderSettings();
const user = (await screen.findByLabelText('Benutzername', {
selector: '#domains-demo-user',
})) as HTMLInputElement;
@@ -195,7 +317,7 @@ describe('Einstellungen', () => {
resolveTest = resolve;
}),
);
render(<DomainsPage />);
await renderSettings();
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
@@ -213,7 +335,7 @@ describe('Einstellungen', () => {
});
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
render(<DomainsPage />);
await renderSettings();
const password = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
@@ -227,7 +349,7 @@ describe('Einstellungen', () => {
});
it('Nameserver: speichert die bereinigte Liste', async () => {
render(<DomainsPage />);
await renderSettings();
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
@@ -241,3 +363,157 @@ describe('Einstellungen', () => {
});
});
});
describe('Reiter Domains, Kontakte und Kunden', () => {
it('Benutzen-Benutzer sieht Domains, Kontakte, Kunden, aber weder Einstellungen noch Schreibknoepfe', async () => {
mockCanManage = false;
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Domains' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
expect(screen.queryByRole('button', { name: 'Neuer Kontakt' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Zuordnen' })).toBeNull();
expect(screen.queryByRole('checkbox', { name: /auswählen/ })).toBeNull();
// Lesen darf jeder: neu einlesen steht allen zur Verfuegung.
expect(screen.getByRole('button', { name: 'Aus AutoDNS neu einlesen' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
await screen.findByText('Alpha GmbH');
expect(screen.queryByRole('button', { name: 'Kunde anlegen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Bearbeiten' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Löschen' })).toBeNull();
});
it('Verwalter sieht die Schreibknoepfe', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
expect(await screen.findByRole('button', { name: 'Neuer Kontakt' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Zuordnen' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
expect(await screen.findByRole('button', { name: 'Kunde anlegen' })).toBeTruthy();
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(2);
});
it('Domainliste: Gruppen je Kunde, "Nicht zugeordnet" zuletzt, Datum und Statusbeschriftung', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
const titles = Array.from(document.querySelectorAll('.cc-section-title')).map(
(h) => h.textContent,
);
expect(titles).toEqual(['Unsere Firma', 'Alpha GmbH', 'Nicht zugeordnet']);
const row = screen.getByText('alpha.de').closest('tr') as HTMLElement;
expect(within(row).getByText('15.07.2027')).toBeTruthy();
expect(within(row).getByText(/Gesperrt/)).toBeTruthy();
expect(within(row).getByText('Kündigung vorgemerkt')).toBeTruthy();
const frei = screen.getByText('frei.de').closest('tr') as HTMLElement;
expect(within(frei).getByText('01.03.2027')).toBeTruthy();
expect(within(frei).getByText('Aktiv')).toBeTruthy();
// Unbekannter Status: Rohwert als Rueckfall, fehlendes Datum als Strich.
const eigen = screen.getByText('eigen.de').closest('tr') as HTMLElement;
expect(within(eigen).getByText('SONDERWERT')).toBeTruthy();
});
it('Kundenfilter blendet die anderen Gruppen aus', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
target: { value: 'c-a' },
});
expect(screen.getByText('alpha.de')).toBeTruthy();
expect(screen.queryByText('frei.de')).toBeNull();
expect(screen.queryByText('eigen.de')).toBeNull();
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
target: { value: 'unassigned' },
});
expect(screen.getByText('frei.de')).toBeTruthy();
expect(screen.queryByText('alpha.de')).toBeNull();
});
it('Textsuche und "Aus AutoDNS neu laden"', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
fireEvent.change(document.getElementById('domains-search') as HTMLInputElement, {
target: { value: 'FREI' },
});
expect(screen.queryByText('alpha.de')).toBeNull();
expect(screen.getByText('frei.de')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Aus AutoDNS neu laden' }));
await waitFor(() => expect(mockListDomains).toHaveBeenLastCalledWith({ refresh: true }));
});
it('zeigt den Hinweis bei abgeschnittener Liste und die Fehlermeldung der API', async () => {
mockListDomains.mockResolvedValueOnce({ ...domainList(), truncated: true });
render(<DomainsPage />);
expect(await screen.findByText('Es werden die ersten 2000 Einträge angezeigt.')).toBeTruthy();
});
it('Kontakte zuordnen: ausgewaehlte Kontakte gehen an den gewaehlten Kunden', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Bert Beta auswählen' }));
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
target: { value: 'c-a' },
});
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledTimes(1));
expect(mockAssignContacts).toHaveBeenCalledWith([5], 'c-a');
});
it('Zuordnung entfernen sendet null', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Alpha GmbH auswählen' }));
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
target: { value: '__remove__' },
});
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledWith([6], null));
});
it('Kunden: Loeschen verlangt eine Bestaetigung und zeigt den 409-Text der API', async () => {
const { DomainsRequestError } = await import('@/lib/domains-api');
mockDeleteCustomer.mockRejectedValueOnce(
new DomainsRequestError(409, 'customerInUse', 'Diesem Kunden sind noch Kontakte zugeordnet.'),
);
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
const buttons = await screen.findAllByRole('button', { name: 'Löschen' });
fireEvent.click(buttons[1]);
const dialog = await screen.findByRole('alertdialog');
expect(mockDeleteCustomer).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Endgültig löschen' }));
await waitFor(() => expect(mockDeleteCustomer).toHaveBeenCalledWith('c-a'));
expect(await screen.findByText('Diesem Kunden sind noch Kontakte zugeordnet.')).toBeTruthy();
});
it('Kunden: neuer Kunde mit Markierung eigene Firma', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
fireEvent.change(await screen.findByLabelText('Name', { selector: '#customer-new-name' }), {
target: { value: ' Neu AG ' },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'Das ist unsere eigene Firma' }));
fireEvent.click(screen.getByRole('button', { name: 'Kunde anlegen' }));
await waitFor(() => expect(mockCreateCustomer).toHaveBeenCalledTimes(1));
expect(mockCreateCustomer).toHaveBeenCalledWith({ name: 'Neu AG', isOwnCompany: true });
});
it('nicht eingerichtet: keine Liste, nur der Hinweis', async () => {
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
await screen.findByText('AutoDNS ist noch nicht eingerichtet.');
expect(mockListDomains).not.toHaveBeenCalled();
});
});
@@ -5,19 +5,29 @@ import { useCallback, useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { SettingsSection } from '@/components/control-center/settings-section';
import { PageHeader } from '@/components/layout/page-header';
import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api';
import {
type DomainsCustomer,
type DomainsStatus,
getDomainsStatus,
listCustomers,
} from '@/lib/domains-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { ContactsTab } from './components/ContactsTab';
import { CustomersTab } from './components/CustomersTab';
import { DomainsTab } from './components/DomainsTab';
import { EnvironmentBadge } from './components/EnvironmentBadge';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'settings';
type TabId = 'domains' | 'contacts' | 'customers' | 'settings';
/**
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
* aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte und Kunden sehen alle
* mit "Benutzen"; "Einstellungen" und die Schreibknoepfe erscheinen nur fuer
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
* allein die API.
* allein die API. Die Kundenliste wird einmal hier geladen und nach unten
* gereicht (Filter, Zuordnung, Formular).
*/
export default function DomainsPage() {
const t = useTranslations('domains');
@@ -25,7 +35,8 @@ export default function DomainsPage() {
const [status, setStatus] = useState<DomainsStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('settings');
const [tab, setTab] = useState<TabId>('domains');
const [customers, setCustomers] = useState<DomainsCustomer[]>([]);
const reloadStatus = useCallback(async () => {
try {
@@ -36,11 +47,24 @@ export default function DomainsPage() {
}
}, []);
const reloadCustomers = useCallback(async () => {
try {
setCustomers(await listCustomers());
} catch {
// Die Kundenliste ist Beiwerk (Filter, Zuordnung); die Reiter melden eigene Fehler.
}
}, []);
useEffect(() => {
void reloadStatus();
}, [reloadStatus]);
void reloadCustomers();
}, [reloadStatus, reloadCustomers]);
const tabs: { id: TabId; label: string }[] = [];
const tabs: { id: TabId; label: string }[] = [
{ id: 'domains', label: t('tabs.domains') },
{ id: 'contacts', label: t('tabs.contacts') },
{ id: 'customers', label: t('tabs.customers') },
];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
@@ -76,7 +100,25 @@ export default function DomainsPage() {
{tabs.length > 0 && activeTab && (
<>
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
{activeTab === 'settings' && <SettingsTab onChanged={reloadStatus} />}
{activeTab === 'domains' && status?.configured && <DomainsTab customers={customers} />}
{activeTab === 'contacts' && status?.configured && (
<ContactsTab
customers={customers}
canManage={canManage}
onCustomersChanged={reloadCustomers}
/>
)}
{activeTab === 'customers' && (
<CustomersTab customers={customers} canManage={canManage} onChanged={reloadCustomers} />
)}
{activeTab === 'settings' && (
<SettingsTab
onChanged={() => {
void reloadStatus();
void reloadCustomers();
}}
/>
)}
</>
)}
</div>