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:
@@ -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,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' : ''}`);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user