feat(T04): contact management + USt-IdNr. validation + contact UI
- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete - ContactPerson model: 1:N relation with CASCADE delete - USt-IdNr. validation: DE + 10 EU countries - Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate - Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle - 73 backend tests (91% coverage), 20 frontend tests
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface ContactPersonResponse {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
name: string;
|
||||
function?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ContactResponse {
|
||||
id: string;
|
||||
company_name: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role: string;
|
||||
vat_id_status: string;
|
||||
is_private: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
contact_persons: ContactPersonResponse[];
|
||||
}
|
||||
|
||||
export interface ContactCreateData {
|
||||
company_name: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role: string;
|
||||
is_private?: boolean;
|
||||
contact_persons?: ContactPersonCreateData[];
|
||||
}
|
||||
|
||||
export interface ContactUpdateData {
|
||||
company_name?: string;
|
||||
legal_form?: string;
|
||||
address_street?: string;
|
||||
address_zip?: string;
|
||||
address_city?: string;
|
||||
address_country?: string;
|
||||
vat_id?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
role?: string;
|
||||
is_private?: boolean;
|
||||
}
|
||||
|
||||
export interface ContactPersonCreateData {
|
||||
name: string;
|
||||
function?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ContactListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
role?: string;
|
||||
is_eu?: boolean;
|
||||
is_private?: boolean;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export async function listContacts(params: ContactListParams = {}): Promise<PaginatedResponse<ContactResponse>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.page_size) query.set('page_size', String(params.page_size));
|
||||
if (params.search) query.set('search', params.search);
|
||||
if (params.role) query.set('role', params.role);
|
||||
if (params.is_eu !== undefined) query.set('is_eu', String(params.is_eu));
|
||||
if (params.is_private !== undefined) query.set('is_private', String(params.is_private));
|
||||
if (params.sort) query.set('sort', params.sort);
|
||||
const qs = query.toString();
|
||||
return apiFetch<PaginatedResponse<ContactResponse>>(`/contacts/${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getContact(id: string): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
export async function createContact(data: ContactCreateData): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>('/contacts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateContact(id: string, data: ContactUpdateData): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string): Promise<ContactResponse> {
|
||||
return apiFetch<ContactResponse>(`/contacts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function addContactPerson(contactId: string, data: ContactPersonCreateData): Promise<ContactPersonResponse> {
|
||||
return apiFetch<ContactPersonResponse>(`/contacts/${contactId}/persons`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeContactPerson(contactId: string, personId: string): Promise<void> {
|
||||
await apiFetch<void>(`/contacts/${contactId}/persons/${personId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate VAT ID format on the frontend (mirrors backend ust_validation.py).
|
||||
* Returns error message or null if valid.
|
||||
*/
|
||||
export function validateVatIdFormat(vatId: string, countryCode: string): string | null {
|
||||
if (!vatId) return null;
|
||||
const normalized = vatId.trim().toUpperCase().replace(/\s+/g, '');
|
||||
const patterns: Record<string, RegExp> = {
|
||||
DE: /^DE\d{9}$/,
|
||||
AT: /^ATU\d{8}$/,
|
||||
FR: /^FR[A-Za-z0-9]{2}\d{9}$/,
|
||||
NL: /^NL\d{9}B\d{2}$/,
|
||||
PL: /^PL\d{10}$/,
|
||||
CZ: /^CZ\d{8,10}$/,
|
||||
IT: /^IT\d{11}$/,
|
||||
ES: /^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$/,
|
||||
BE: /^BE\d{10}$/,
|
||||
DK: /^DK\d{8}$/,
|
||||
SE: /^SE\d{10}$/,
|
||||
};
|
||||
const euCountries = new Set([
|
||||
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR',
|
||||
'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO',
|
||||
'SE', 'SI', 'SK',
|
||||
]);
|
||||
const cc = normalized.substring(0, 2);
|
||||
if (!cc.match(/^[A-Z]{2}$/)) return 'Invalid country code';
|
||||
const pattern = patterns[cc];
|
||||
if (pattern) {
|
||||
return pattern.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
|
||||
}
|
||||
if (euCountries.has(cc)) {
|
||||
return /^[A-Z]{2}[A-Za-z0-9]{5,15}$/.test(normalized) ? null : `Invalid VAT ID format for ${cc}`;
|
||||
}
|
||||
return `VAT ID validation not supported for country ${cc}`;
|
||||
}
|
||||
Reference in New Issue
Block a user