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>
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
ALL_CUSTOMERS,
filterByCustomer,
groupByCustomer,
matchesText,
UNASSIGNED,
} from './group-by-customer';
interface Item {
name: string;
customerId: string | null;
customerName: string | null;
}
const customers = [
{ id: 'c-z', name: 'Zeller GmbH', isOwnCompany: false },
{ id: 'c-ae', name: 'Äpfel AG', isOwnCompany: false },
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true },
{ id: 'c-b', name: 'Bauer', isOwnCompany: false },
];
const items: Item[] = [
{ name: 'a.de', customerId: null, customerName: null },
{ name: 'b.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
{ name: 'c.de', customerId: 'c-own', customerName: 'Unsere Firma' },
{ name: 'd.de', customerId: 'c-ae', customerName: 'Äpfel AG' },
{ name: 'e.de', customerId: 'c-b', customerName: 'Bauer' },
{ name: 'f.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
];
describe('groupByCustomer', () => {
it('eigene Firma zuerst, dann deutsch sortiert, "Nicht zugeordnet" zuletzt', () => {
const groups = groupByCustomer(items, customers);
expect(groups.map((g) => g.key)).toEqual(['c-own', 'c-ae', 'c-b', 'c-z', UNASSIGNED]);
expect(groups[0].isOwnCompany).toBe(true);
expect(groups[4].name).toBeNull();
expect(groups.find((g) => g.key === 'c-z')?.items.map((i) => i.name)).toEqual(['b.de', 'f.de']);
});
it('laesst leere Gruppen weg und veraendert die Eingabe nicht', () => {
const copy = JSON.parse(JSON.stringify(items));
const groups = groupByCustomer(items.slice(0, 1), customers);
expect(groups).toHaveLength(1);
expect(groups[0].key).toBe(UNASSIGNED);
groupByCustomer(items, customers);
expect(items).toEqual(copy);
});
it('ein Kunde, den die Kundenliste nicht kennt, behaelt seinen Namen aus dem Eintrag', () => {
const groups = groupByCustomer(
[{ name: 'x.de', customerId: 'c-weg', customerName: 'Gelöscht AG' }],
customers,
);
expect(groups[0].name).toBe('Gelöscht AG');
});
});
describe('filterByCustomer', () => {
it('all, unassigned und eine Kunden-Id', () => {
expect(filterByCustomer(items, ALL_CUSTOMERS)).toHaveLength(6);
expect(filterByCustomer(items, UNASSIGNED).map((i) => i.name)).toEqual(['a.de']);
expect(filterByCustomer(items, 'c-z').map((i) => i.name)).toEqual(['b.de', 'f.de']);
expect(filterByCustomer(items, 'c-gibt-es-nicht')).toEqual([]);
});
it('gibt bei "all" eine Kopie zurueck, nicht die Eingabe', () => {
expect(filterByCustomer(items, ALL_CUSTOMERS)).not.toBe(items);
});
});
describe('matchesText', () => {
const fields = [(i: Item) => i.name, (i: Item) => i.customerName];
it('ignoriert Gross-/Kleinschreibung und durchsucht alle Felder', () => {
expect(matchesText(items[1], 'ZELLER', fields)).toBe(true);
expect(matchesText(items[1], 'b.de', fields)).toBe(true);
expect(matchesText(items[1], 'xyz', fields)).toBe(false);
});
it('leere Suche trifft alles, null-Felder stoeren nicht', () => {
expect(matchesText(items[0], ' ', fields)).toBe(true);
expect(matchesText(items[0], 'zeller', fields)).toBe(false);
});
});
@@ -0,0 +1,82 @@
/**
* Filtern, Suchen und Gruppieren nach Kunde (quick-261008-dts). Reine
* Hilfsfunktionen ohne React: Kontakte und Domains tragen beide
* `customerId`/`customerName`. Die Eingabelisten werden nie veraendert.
*/
/** Schluessel fuer "keinem Kunden zugeordnet" (Filterwert und Gruppenschluessel). */
export const UNASSIGNED = 'unassigned';
/** Filterwert "alle Kunden". */
export const ALL_CUSTOMERS = 'all';
export interface HasCustomer {
customerId: string | null;
customerName: string | null;
}
export interface CustomerRef {
id: string;
name: string;
isOwnCompany: boolean;
}
export interface CustomerGroup<T> {
/** Kunden-Id oder `UNASSIGNED`. */
key: string;
/** Anzeigename; `null` bei der Gruppe "Nicht zugeordnet" (Beschriftung kommt aus den Texten). */
name: string | null;
isOwnCompany: boolean;
items: T[];
}
/** `'all'`, `'unassigned'` oder eine Kunden-Id. */
export function filterByCustomer<T extends HasCustomer>(items: T[], filter: string): T[] {
if (filter === ALL_CUSTOMERS) return [...items];
if (filter === UNASSIGNED) return items.filter((item) => item.customerId === null);
return items.filter((item) => item.customerId === filter);
}
/**
* Gruppen nach Kunde: eigene Firma zuerst, dann nach Name (deutsche
* Sortierung), "Nicht zugeordnet" zuletzt. Leere Gruppen entfallen.
*/
export function groupByCustomer<T extends HasCustomer>(
items: T[],
customers: CustomerRef[],
): CustomerGroup<T>[] {
const known = new Map(customers.map((c) => [c.id, c]));
const groups = new Map<string, CustomerGroup<T>>();
for (const item of items) {
const key = item.customerId ?? UNASSIGNED;
let group = groups.get(key);
if (!group) {
const customer = item.customerId ? known.get(item.customerId) : undefined;
group = {
key,
name: item.customerId ? (customer?.name ?? item.customerName ?? item.customerId) : null,
isOwnCompany: customer?.isOwnCompany ?? false,
items: [],
};
groups.set(key, group);
}
group.items.push(item);
}
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
return [...groups.values()].sort((a, b) => {
if (a.key === UNASSIGNED) return 1;
if (b.key === UNASSIGNED) return -1;
if (a.isOwnCompany !== b.isOwnCompany) return a.isOwnCompany ? -1 : 1;
return collator.compare(a.name ?? '', b.name ?? '');
});
}
/** Gross-/Kleinschreibung egal; leere Suche trifft alles. */
export function matchesText<T>(
item: T,
query: string,
fields: ((item: T) => string | null | undefined)[],
): boolean {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return fields.some((field) => (field(item) ?? '').toLowerCase().includes(needle));
}
@@ -0,0 +1,10 @@
/** Gemeinsame Klassen der Domains-Seite (Muster `SettingsTab.tsx`). */
export const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
export const PRIMARY_BUTTON =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
export const SECONDARY_BUTTON =
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
export const TABLE_HEAD_CLASS = 'bg-muted/50 text-xs text-muted-foreground';
export const TABLE_HEAD_CELL = 'px-3 py-2 font-medium';
export const TABLE_CELL = 'px-3 py-2';
+122
View File
@@ -122,3 +122,125 @@ export function testDomainsConnection(
json: { environment },
});
}
// --- Kunden, Kontakte und Domains (quick-261008-dts, Aufgabe 2) ----------------
export interface DomainsCustomer {
id: string;
name: string;
isOwnCompany: boolean;
/** Zugeordnete Kontakte im AKTIVEN System. */
contactCount: number;
}
export interface DomainsCustomerInput {
name: string;
isOwnCompany?: boolean;
}
export type DomainsContactType = 'PERSON' | 'ORG';
export interface DomainsContact {
/** AutoDNS-Kontakt-Nummer. */
id: number;
type: string | null;
displayName: string;
fname: string | null;
lname: string | null;
organization: string | null;
address: string[];
pcode: string | null;
city: string | null;
country: string | null;
email: string | null;
phone: string | null;
alias: string | null;
customerId: string | null;
customerName: string | null;
}
export interface DomainsContactList {
environment: DomainsEnvironment;
contacts: DomainsContact[];
/** Es werden nur die ersten 2000 Eintraege geliefert. */
truncated: boolean;
fetchedAt: string;
}
export interface DomainsDomain {
name: string;
/** ISO-Zeitpunkt oder null. */
expire: string | null;
/** Rohwert von AutoDNS (`registryStatus`). */
status: string | null;
cancelationPending: boolean;
ownerContactId: number | null;
ownerName: string | null;
customerId: string | null;
customerName: string | null;
}
export interface DomainsDomainList {
environment: DomainsEnvironment;
domains: DomainsDomain[];
truncated: boolean;
fetchedAt: string;
}
export interface CreateDomainsContactInput {
type: DomainsContactType;
organization?: string;
fname: string;
lname: string;
/** Anschriftzeilen (Strasse und Hausnummer, optional eine zweite Zeile). */
street: string[];
pcode: string;
city: string;
/** Laendercode nach ISO 3166-1 (zwei Grossbuchstaben). */
country: string;
email: string;
phone: string;
customerId?: string;
}
export function listCustomers(): Promise<DomainsCustomer[]> {
return request<DomainsCustomer[]>('/customers');
}
export function createCustomer(input: DomainsCustomerInput): Promise<DomainsCustomer> {
return request<DomainsCustomer>('/customers', { method: 'POST', json: input });
}
export function updateCustomer(id: string, input: DomainsCustomerInput): Promise<DomainsCustomer> {
return request<DomainsCustomer>(`/customers/${encodeURIComponent(id)}`, {
method: 'PUT',
json: input,
});
}
export function deleteCustomer(id: string): Promise<{ deleted: true }> {
return request<{ deleted: true }>(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export function listContacts(opts: { refresh?: boolean } = {}): Promise<DomainsContactList> {
return request<DomainsContactList>(`/contacts${opts.refresh ? '?refresh=1' : ''}`);
}
export function createContact(input: CreateDomainsContactInput): Promise<DomainsContact> {
return request<DomainsContact>('/contacts', { method: 'POST', json: input });
}
/** `customerId: null` loest die Zuordnung. */
export function assignContacts(
contactIds: number[],
customerId: string | null,
): Promise<{ assigned: number; removed: number }> {
return request<{ assigned: number; removed: number }>('/contacts/assign', {
method: 'POST',
json: { contactIds, customerId },
});
}
export function listDomains(opts: { refresh?: boolean } = {}): Promise<DomainsDomainList> {
return request<DomainsDomainList>(`/domains${opts.refresh ? '?refresh=1' : ''}`);
}
+102
View File
@@ -2058,6 +2058,9 @@
"notConfigured": "AutoDNS nicht eingerichtet"
},
"tabs": {
"domains": "Domains",
"contacts": "Kontakte",
"customers": "Kunden",
"settings": "Einstellungen"
},
"notConfigured": {
@@ -2104,6 +2107,105 @@
"remove": "Entfernen"
}
},
"common": {
"customerFilter": "Kunde",
"allCustomers": "Alle Kunden",
"unassigned": "Nicht zugeordnet",
"groupByCustomer": "Nach Kunde gruppieren",
"loading": "Wird geladen …",
"truncated": "Es werden die ersten 2000 Einträge angezeigt.",
"requestFailed": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"domainList": {
"search": "Domain suchen",
"refresh": "Aus AutoDNS neu laden",
"empty": "Bei AutoDNS wurden keine Domains gefunden.",
"emptyFiltered": "Keine Domain entspricht Ihrer Auswahl.",
"unassigned": "Nicht zugeordnet",
"cancelPending": "Kündigung vorgemerkt",
"groupCount": "{count, plural, one {# Domain} other {# Domains}}",
"columns": {
"domain": "Domain",
"customer": "Kunde",
"owner": "Inhaber",
"expires": "Ablaufdatum",
"status": "Status"
},
"status": {
"ACTIVE": "Aktiv",
"PENDING": "In Bearbeitung",
"HOLD": "Gesperrt (Registry)",
"LOCK": "Gesperrt"
}
},
"contactList": {
"intro": "Hier erscheinen alle Kontakte, die bei AutoDNS im gewählten System vorhanden sind. Ordnen Sie sie einem Kunden zu, damit Domains beim richtigen Kunden auftauchen.",
"search": "Kontakt suchen",
"refresh": "Aus AutoDNS neu einlesen",
"newContact": "Neuer Kontakt",
"empty": "Bei AutoDNS wurden keine Kontakte gefunden.",
"emptyFiltered": "Kein Kontakt entspricht Ihrer Auswahl.",
"groupCount": "{count, plural, one {# Kontakt} other {# Kontakte}}",
"columns": {
"name": "Name",
"organization": "Organisation",
"city": "Ort",
"email": "E-Mail",
"customer": "Kunde"
},
"selectContact": "Kontakt {name} auswählen",
"selectAllVisible": "Alle angezeigten auswählen",
"selectedCount": "{count, plural, =0 {Nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}",
"assignLabel": "Ausgewählte zuordnen",
"assignPlaceholder": "Kunde wählen …",
"removeAssignment": "Zuordnung entfernen",
"assign": "Zuordnen",
"assigned": "Die Zuordnung wurde gespeichert.",
"created": "Der Kontakt wurde bei AutoDNS angelegt."
},
"contactForm": {
"title": "Neuer Kontakt",
"type": "Typ",
"person": "Person",
"org": "Firma oder Organisation",
"organization": "Organisation",
"fname": "Vorname",
"lname": "Nachname",
"street": "Straße und Hausnummer",
"street2": "Zweite Adresszeile (optional)",
"pcode": "PLZ",
"city": "Ort",
"country": "Land",
"phone": "Telefon",
"phoneHint": "Internationale Schreibweise, z. B. +49 30 123456",
"email": "E-Mail",
"customer": "Kunde (optional)",
"noCustomer": "Kein Kunde",
"save": "Speichern",
"saving": "Speichern …",
"cancel": "Abbrechen",
"errors": {
"required": "Bitte füllen Sie dieses Feld aus.",
"email": "Bitte geben Sie eine gültige E-Mail-Adresse an.",
"phone": "Bitte verwenden Sie die internationale Schreibweise, z. B. +49 30 123456."
}
},
"customers": {
"intro": "Kunden fassen AutoDNS-Kontakte zusammen. Eine Domain erscheint beim Kunden ihres Inhabers. Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie.",
"addTitle": "Kunde anlegen",
"name": "Name",
"ownCompanyLabel": "Das ist unsere eigene Firma",
"ownCompany": "Eigene Firma",
"add": "Kunde anlegen",
"empty": "Es sind noch keine Kunden angelegt.",
"contactCount": "{count, plural, =0 {Keine Kontakte} one {# Kontakt} other {# Kontakte}}",
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"confirmDelete": "Kunde „{name}“ wirklich löschen?",
"confirmDeleteYes": "Endgültig löschen"
},
"errors": {
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
+102
View File
@@ -2058,6 +2058,9 @@
"notConfigured": "AutoDNS not set up"
},
"tabs": {
"domains": "Domains",
"contacts": "Contacts",
"customers": "Customers",
"settings": "Settings"
},
"notConfigured": {
@@ -2104,6 +2107,105 @@
"remove": "Remove"
}
},
"common": {
"customerFilter": "Customer",
"allCustomers": "All customers",
"unassigned": "Unassigned",
"groupByCustomer": "Group by customer",
"loading": "Loading …",
"truncated": "Only the first 2000 entries are shown.",
"requestFailed": "The request failed. Please try again."
},
"domainList": {
"search": "Search domains",
"refresh": "Reload from AutoDNS",
"empty": "No domains were found at AutoDNS.",
"emptyFiltered": "No domain matches your selection.",
"unassigned": "Unassigned",
"cancelPending": "Cancellation pending",
"groupCount": "{count, plural, one {# domain} other {# domains}}",
"columns": {
"domain": "Domain",
"customer": "Customer",
"owner": "Owner",
"expires": "Expiry date",
"status": "Status"
},
"status": {
"ACTIVE": "Active",
"PENDING": "In progress",
"HOLD": "On hold (registry)",
"LOCK": "Locked"
}
},
"contactList": {
"intro": "All contacts that exist at AutoDNS in the selected system appear here. Assign them to a customer so that domains show up under the right customer.",
"search": "Search contacts",
"refresh": "Re-read from AutoDNS",
"newContact": "New contact",
"empty": "No contacts were found at AutoDNS.",
"emptyFiltered": "No contact matches your selection.",
"groupCount": "{count, plural, one {# contact} other {# contacts}}",
"columns": {
"name": "Name",
"organization": "Organisation",
"city": "City",
"email": "E-mail",
"customer": "Customer"
},
"selectContact": "Select contact {name}",
"selectAllVisible": "Select all shown",
"selectedCount": "{count, plural, =0 {Nothing selected} other {# selected}}",
"assignLabel": "Assign selected to",
"assignPlaceholder": "Choose customer …",
"removeAssignment": "Remove assignment",
"assign": "Assign",
"assigned": "The assignment was saved.",
"created": "The contact was created at AutoDNS."
},
"contactForm": {
"title": "New contact",
"type": "Type",
"person": "Person",
"org": "Company or organisation",
"organization": "Organisation",
"fname": "First name",
"lname": "Last name",
"street": "Street and house number",
"street2": "Second address line (optional)",
"pcode": "Postal code",
"city": "City",
"country": "Country",
"phone": "Phone",
"phoneHint": "International format, e.g. +49 30 123456",
"email": "E-mail",
"customer": "Customer (optional)",
"noCustomer": "No customer",
"save": "Save",
"saving": "Saving …",
"cancel": "Cancel",
"errors": {
"required": "Please fill in this field.",
"email": "Please enter a valid e-mail address.",
"phone": "Please use the international format, e.g. +49 30 123456."
}
},
"customers": {
"intro": "Customers group AutoDNS contacts. A domain appears under the customer of its owner. Add your own company as a customer too and mark it.",
"addTitle": "Add customer",
"name": "Name",
"ownCompanyLabel": "This is our own company",
"ownCompany": "Own company",
"add": "Add customer",
"empty": "No customers have been added yet.",
"contactCount": "{count, plural, =0 {No contacts} one {# contact} other {# contacts}}",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirmDelete": "Really delete customer “{name}”?",
"confirmDeleteYes": "Delete permanently"
},
"errors": {
"request": "The request failed. Please try again.",
"loadStatus": "The state of the AutoDNS connection could not be loaded."
@@ -225,4 +225,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'aktuellen',
'Passworts',
'tessera',
// quick-261008-dts: Modul Domains, Kunden und Kontakte — korrektes Deutsch mit „ss“
'Adresszeile',
'fassen',
];