intermittent commit

This commit is contained in:
2026-07-14 13:22:09 +02:00
parent 420120af2f
commit f19350e65d
32 changed files with 11772 additions and 11 deletions

28
webui/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/addresses.css": "./src/styles/addresses.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

596
webui/src/api/addresses.ts Normal file
View File

@@ -0,0 +1,596 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type AddressBookScope = "user" | "group" | "tenant" | "system";
export type AddressBook = {
id: string;
tenant_id?: string | null;
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_kind: string;
source_ref?: string | null;
read_only: boolean;
sync_status?: string | null;
sync_error?: string | null;
contact_count: number;
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type AddressList = {
id: string;
tenant_id?: string | null;
address_book_id: string;
name: string;
description?: string | null;
source_kind: string;
source_ref?: string | null;
read_only: boolean;
entry_count: number;
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type ContactEmail = {
id?: string;
label?: string | null;
email: string;
is_primary: boolean;
};
export type ContactPhone = {
id?: string;
label?: string | null;
phone: string;
is_primary: boolean;
};
export type ContactPostalAddress = {
id?: string;
label?: string | null;
street?: string | null;
postal_code?: string | null;
locality?: string | null;
region?: string | null;
country?: string | null;
is_primary: boolean;
};
export type Contact = {
id: string;
tenant_id?: string | null;
address_book_id: string;
display_name: string;
given_name?: string | null;
family_name?: string | null;
organization?: string | null;
role_title?: string | null;
note?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_payload_kind?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
emails: ContactEmail[];
phones: ContactPhone[];
postal_addresses: ContactPostalAddress[];
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type AddressBookCreatePayload = {
scope_type: AddressBookScope;
group_id?: string | null;
name: string;
description?: string | null;
};
export type AddressBookUpdatePayload = {
name?: string | null;
description?: string | null;
};
export type AddressListPayload = {
name: string;
description?: string | null;
};
export type AddressListEntryPayload = {
contact_id: string;
contact_email_id?: string | null;
contact_postal_address_id?: string | null;
label?: string | null;
};
export type AddressListEntry = {
id: string;
address_list_id: string;
contact_id: string;
contact_email_id?: string | null;
contact_postal_address_id?: string | null;
target_kind: string;
label?: string | null;
order_index: number;
contact_display_name: string;
email?: string | null;
postal_address?: string | null;
created_at: string;
updated_at: string;
};
export type ContactPayload = {
display_name?: string | null;
given_name?: string | null;
family_name?: string | null;
organization?: string | null;
role_title?: string | null;
note?: string | null;
tags?: string[];
emails?: Array<Omit<ContactEmail, "id">>;
phones?: Array<Omit<ContactPhone, "id">>;
postal_addresses?: Array<Omit<ContactPostalAddress, "id">>;
provenance?: Record<string, unknown>;
};
export type AddressBookWriteOperation = "create_contact" | "update_contact" | "delete_contact";
export type AddressBookWriteDecision = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: AddressBookScope | null;
scope_id?: string | null;
tenant_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
type AddressBookListResponse = {
address_books: AddressBook[];
};
type AddressListListResponse = {
address_lists: AddressList[];
};
type AddressListEntryListResponse = {
entries: AddressListEntry[];
};
type ContactListResponse = {
contacts: Contact[];
};
type AddressBookWriteTargetsResponse = {
targets: AddressBookWriteDecision[];
};
export type VCardImportResult = {
imported: number;
skipped: number;
contacts: Contact[];
issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>;
};
export type AddressSyncSource = {
id: string;
tenant_id?: string | null;
address_book_id: string;
connector_type: string;
display_name: string;
external_account_ref?: string | null;
external_address_book_ref?: string | null;
sync_direction: string;
read_only: boolean;
enabled: boolean;
status: string;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
last_attempted_at?: string | null;
last_success_at?: string | null;
last_error?: string | null;
last_diagnostic?: Record<string, unknown> | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressCardDavAddressBook = {
collection_url: string;
href: string;
display_name?: string | null;
description?: string | null;
ctag?: string | null;
sync_token?: string | null;
};
export type AddressSyncPlanStats = {
created: number;
updated: number;
deleted: number;
conflicts: number;
unchanged: number;
errors: number;
fetched: number;
full_sync: boolean;
used_sync_token: boolean;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
};
export type AddressSyncPlanItem = {
action: "create" | "update" | "delete" | "remote_create" | "remote_update" | "remote_delete" | "conflict" | "unchanged" | "error";
href?: string | null;
remote_uid?: string | null;
contact_id?: string | null;
display_name?: string | null;
etag?: string | null;
message?: string | null;
};
export type AddressSyncPlan = {
sync_source: AddressSyncSource;
stats: AddressSyncPlanStats;
items: AddressSyncPlanItem[];
};
export type AddressSyncDiagnostic = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
severity: string;
code: string;
message: string;
details: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressSyncTombstone = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
address_book_id: string;
contact_id?: string | null;
remote_uid?: string | null;
resource_href?: string | null;
local_deleted_at?: string | null;
remote_deleted_at?: string | null;
synced_at?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressSyncConflict = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
address_book_id: string;
contact_id?: string | null;
remote_uid?: string | null;
resource_href?: string | null;
field_path: string;
local_value?: Record<string, unknown> | null;
remote_value?: Record<string, unknown> | null;
local_updated_at?: string | null;
remote_updated_at?: string | null;
status: string;
resolution?: string | null;
resolved_at?: string | null;
resolved_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
type AddressSyncSourceListResponse = {
sync_sources: AddressSyncSource[];
};
type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[];
};
type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[];
};
type AddressSyncTombstoneListResponse = {
tombstones: AddressSyncTombstone[];
};
type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[];
};
function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== "") search.set(key, String(value));
}
const serialized = search.toString();
return serialized ? `?${serialized}` : "";
}
export async function listAddressBooks(settings: ApiSettings, options: {includeDeleted?: boolean;} = {}): Promise<AddressBook[]> {
const response = await apiFetch<AddressBookListResponse>(
settings,
`/api/v1/addresses/address-books${queryString({ include_deleted: options.includeDeleted ? "true" : null })}`
);
return response.address_books;
}
export function createAddressBook(settings: ApiSettings, payload: AddressBookCreatePayload): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, "/api/v1/addresses/address-books", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressBook(settings: ApiSettings, bookId: string, payload: AddressBookUpdatePayload): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, `/api/v1/addresses/address-books/${bookId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressBook(settings: ApiSettings, bookId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-books/${bookId}`, { method: "DELETE" });
}
export function restoreAddressBook(settings: ApiSettings, bookId: string): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, `/api/v1/addresses/address-books/${bookId}/restore`, { method: "POST" });
}
export async function listAddressLists(
settings: ApiSettings,
options: { addressBookId?: string | null; includeDeleted?: boolean } = {}
): Promise<AddressList[]> {
const response = await apiFetch<AddressListListResponse>(
settings,
`/api/v1/addresses/address-lists${queryString({
address_book_id: options.addressBookId,
include_deleted: options.includeDeleted ? "true" : null
})}`
);
return response.address_lists;
}
export function createAddressList(settings: ApiSettings, addressBookId: string, payload: AddressListPayload): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-books/${addressBookId}/address-lists`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressList(settings: ApiSettings, addressListId: string, payload: Partial<AddressListPayload>): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-lists/${addressListId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressList(settings: ApiSettings, addressListId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-lists/${addressListId}`, { method: "DELETE" });
}
export function restoreAddressList(settings: ApiSettings, addressListId: string): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-lists/${addressListId}/restore`, { method: "POST" });
}
export async function listAddressListEntries(settings: ApiSettings, addressListId: string): Promise<AddressListEntry[]> {
const response = await apiFetch<AddressListEntryListResponse>(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`);
return response.entries;
}
export function createAddressListEntry(
settings: ApiSettings,
addressListId: string,
payload: AddressListEntryPayload
): Promise<AddressListEntry> {
return apiFetch<AddressListEntry>(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function deleteAddressListEntry(settings: ApiSettings, entryId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-list-entries/${entryId}`, { method: "DELETE" });
}
export async function listAddressBookWriteTargets(
settings: ApiSettings,
operation: AddressBookWriteOperation = "create_contact"
): Promise<AddressBookWriteDecision[]> {
const response = await apiFetch<AddressBookWriteTargetsResponse>(
settings,
`/api/v1/addresses/write-targets${queryString({ operation })}`
);
return response.targets;
}
export function getAddressBookWriteDecision(
settings: ApiSettings,
addressBookId: string,
operation: AddressBookWriteOperation = "create_contact"
): Promise<AddressBookWriteDecision> {
return apiFetch<AddressBookWriteDecision>(
settings,
`/api/v1/addresses/address-books/${addressBookId}/write-decision${queryString({ operation })}`
);
}
export async function listAddressSyncSources(
settings: ApiSettings,
options: { addressBookId?: string | null; includeDisabled?: boolean } = {}
): Promise<AddressSyncSource[]> {
const response = await apiFetch<AddressSyncSourceListResponse>(
settings,
`/api/v1/addresses/sync-sources${queryString({
address_book_id: options.addressBookId,
include_disabled: options.includeDisabled ? "true" : null
})}`
);
return response.sync_sources;
}
export function discoverCardDavAddressBooks(
settings: ApiSettings,
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null }
): Promise<AddressCardDavAddressBook[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
method: "POST",
body: JSON.stringify(payload)
}).then((response) => response.address_books);
}
export function createCardDavSyncSource(
settings: ApiSettings,
addressBookId: string,
payload: {
collection_url: string;
display_name?: string | null;
auth_type: "none" | "basic" | "bearer";
username?: string | null;
password?: string | null;
bearer_token?: string | null;
credential_ref?: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way";
read_only?: boolean | null;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
}
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/address-books/${addressBookId}/carddav/sources`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressSyncSource(
settings: ApiSettings,
syncSourceId: string,
payload: Partial<{
display_name: string | null;
external_account_ref: string | null;
external_address_book_ref: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way";
read_only: boolean | null;
enabled: boolean | null;
sync_token: string | null;
etag: string | null;
remote_revision: string | null;
metadata: Record<string, unknown> | null;
}>
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressSyncSource(settings: ApiSettings, syncSourceId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, { method: "DELETE" });
}
export function previewAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise<AddressSyncPlan> {
return apiFetch<AddressSyncPlan>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/preview`, {
method: "POST",
body: JSON.stringify({ force_full: Boolean(options.forceFull) })
});
}
export function runAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise<AddressSyncPlan> {
return apiFetch<AddressSyncPlan>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/run`, {
method: "POST",
body: JSON.stringify({ force_full: Boolean(options.forceFull) })
});
}
export async function listAddressSyncDiagnostics(settings: ApiSettings, syncSourceId: string): Promise<AddressSyncDiagnostic[]> {
const response = await apiFetch<AddressSyncDiagnosticListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/diagnostics`);
return response.diagnostics;
}
export async function listAddressSyncTombstones(settings: ApiSettings, syncSourceId: string): Promise<AddressSyncTombstone[]> {
const response = await apiFetch<AddressSyncTombstoneListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/tombstones`);
return response.tombstones;
}
export async function listAddressSyncConflicts(settings: ApiSettings, syncSourceId: string, status = "open"): Promise<AddressSyncConflict[]> {
const response = await apiFetch<AddressSyncConflictListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/conflicts${queryString({ status })}`);
return response.conflicts;
}
export function resolveAddressSyncConflict(
settings: ApiSettings,
conflictId: string,
resolution = "manual",
status = "resolved",
options: { mergedPayload?: Record<string, unknown> | null } = {}
): Promise<AddressSyncConflict> {
return apiFetch<AddressSyncConflict>(settings, `/api/v1/addresses/sync-conflicts/${conflictId}/resolve`, {
method: "POST",
body: JSON.stringify({ resolution, status, merged_payload: options.mergedPayload ?? null })
});
}
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
const response = await apiFetch<ContactListResponse>(
settings,
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}`
);
return response.contacts;
}
export function createContact(settings: ApiSettings, addressBookId: string, payload: ContactPayload): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/address-books/${addressBookId}/contacts`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateContact(settings: ApiSettings, contactId: string, payload: ContactPayload): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteContact(settings: ApiSettings, contactId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/contacts/${contactId}`, { method: "DELETE" });
}
export function restoreContact(settings: ApiSettings, contactId: string): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
}
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
method: "POST",
body: JSON.stringify({ content })
});
}
export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`);
}
export function exportContactVcard(settings: ApiSettings, contactId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/contacts/${contactId}/vcard`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV, LDAP, and import connectors can be added later.",
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Choose addresses from personal, group, or tenant sources.",
"i18n:govoplan-addresses.contacts.b0dd615c": "Contacts",
"i18n:govoplan-addresses.data_protection.06e87cd3": "Data Protection",
"i18n:govoplan-addresses.directory.4b892fe0": "Directory",
"i18n:govoplan-addresses.email.84add5b2": "Email",
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorite",
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finance Team",
"i18n:govoplan-addresses.group.171a0606": "Group",
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Group address books",
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
"i18n:govoplan-addresses.import.d6fbc9d2": "Import",
"i18n:govoplan-addresses.manage_sources.ec758de0": "Manage sources",
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock workspace for personal, group, and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.",
"i18n:govoplan-addresses.name.709a2322": "Name",
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "No contacts found.",
"i18n:govoplan-addresses.personal.40f07323": "Personal",
"i18n:govoplan-addresses.personal_address_book.e240066d": "Personal address book",
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Planned address actions",
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private contacts and remembered addresses.",
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private contacts, remembered recipients, and personal distribution lists.",
"i18n:govoplan-addresses.project_office.c35aa9ca": "Project Office",
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "Remember addresses used in campaigns after opt-in.",
"i18n:govoplan-addresses.scope.4651a34e": "Scope",
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Share selected contacts with a group.",
"i18n:govoplan-addresses.shared.50d0d8dd": "Shared",
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Shared contact sets for teams, departments, or campaigns.",
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
"i18n:govoplan-addresses.shared_list.b3c94b39": "Shared list",
"i18n:govoplan-addresses.status.bae7d5be": "Status",
"i18n:govoplan-addresses.sync.905f6309": "Sync",
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
"i18n:govoplan-addresses.tenant.3ca93c78": "Tenant",
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Tenant directory",
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Tenant-wide contacts, functional mailboxes, and approved shared entries.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Use contacts in To, Cc, Bcc, sender, and reply-to fields.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Used recently"
},
de: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV-, LDAP- und Import-Anbindungen können später ergänzt werden.",
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Adressen aus persönlichen, Gruppen- oder Mandantenquellen auswählen.",
"i18n:govoplan-addresses.contacts.b0dd615c": "Kontakte",
"i18n:govoplan-addresses.data_protection.06e87cd3": "Datenschutz",
"i18n:govoplan-addresses.directory.4b892fe0": "Verzeichnis",
"i18n:govoplan-addresses.email.84add5b2": "E-Mail",
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorit",
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finanzteam",
"i18n:govoplan-addresses.group.171a0606": "Gruppe",
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Gruppenadressbücher",
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
"i18n:govoplan-addresses.import.d6fbc9d2": "Importieren",
"i18n:govoplan-addresses.manage_sources.ec758de0": "Quellen verwalten",
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock-Arbeitsbereich für persönliche, Gruppen- und Mandantenadressbücher. Diese Kontakte können später Autovervollständigung und wiederverwendbare Adressauswahlen speisen.",
"i18n:govoplan-addresses.name.709a2322": "Name",
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "Keine Kontakte gefunden.",
"i18n:govoplan-addresses.personal.40f07323": "Persönlich",
"i18n:govoplan-addresses.personal_address_book.e240066d": "Persönliches Adressbuch",
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Geplante Adressaktionen",
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private Kontakte und gemerkte Adressen.",
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private Kontakte, gemerkte Empfänger und persönliche Verteilerlisten.",
"i18n:govoplan-addresses.project_office.c35aa9ca": "Projektbüro",
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "In Kampagnen verwendete Adressen nach Opt-in merken.",
"i18n:govoplan-addresses.scope.4651a34e": "Bereich",
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Ausgewählte Kontakte mit einer Gruppe teilen.",
"i18n:govoplan-addresses.shared.50d0d8dd": "Geteilt",
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Geteilte Kontaktsammlungen für Teams, Abteilungen oder Kampagnen.",
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Geteilte Gruppenadressbücher und Listen.",
"i18n:govoplan-addresses.shared_list.b3c94b39": "Geteilte Liste",
"i18n:govoplan-addresses.status.bae7d5be": "Status",
"i18n:govoplan-addresses.sync.905f6309": "Sync",
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
"i18n:govoplan-addresses.tenant.3ca93c78": "Mandant",
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Mandantenverzeichnis",
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Mandantenweite Kontakte, Funktionspostfächer und freigegebene Einträge.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Kontakte in An-, Cc-, Bcc-, Absender- und Antwortfeldern verwenden.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Kürzlich verwendet"
}
};

3
webui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default } from "./module";
export * from "./module";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";

24
webui/src/module.ts Normal file
View File

@@ -0,0 +1,24 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/addresses.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const addressesModule: PlatformWebModule = {
id: "addresses",
label: "i18n:govoplan-addresses.address_book.f6327f59",
version: "1.0.0",
dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations,
navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }],
routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }]
};
export default addressesModule;

View File

@@ -0,0 +1,688 @@
.address-book-scope-list {
display: grid;
gap: 10px;
}
.address-book-scope-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
}
.address-book-scope-card strong {
display: block;
color: var(--text-strong);
}
.address-book-scope-card p {
margin: 5px 0 0;
color: var(--muted);
}
.address-error {
left: 14px;
margin: 0;
position: absolute;
right: 14px;
top: 10px;
z-index: 2;
}
.address-book-page.address-book-fullscreen {
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
overflow: hidden;
padding: 0;
position: relative;
}
.address-workspace-frame {
height: 100%;
min-height: 0;
}
.address-book-workspace {
background: var(--panel);
border: var(--border-line);
border-radius: 0;
display: grid;
grid-template-columns: minmax(250px, 310px) minmax(280px, 360px) minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: hidden;
}
.address-tree-panel,
.address-list-panel,
.address-detail-panel {
min-height: 0;
min-width: 0;
}
.address-tree-panel {
border-right: var(--border-line);
}
.address-tree-header {
background: var(--panel-header);
}
.address-tree-header .button-row {
flex-wrap: wrap;
}
.address-icon-actions {
max-width: 150px;
}
.address-icon-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.address-tree-filter-row {
border-bottom: var(--border-line);
padding: 10px 12px;
}
.address-tree-filter-row .toggle-switch-row {
margin: 0;
}
.address-tree-list {
padding: 6px 0 10px;
}
.address-book-page .explorer-tree-node .address-tree-node-content {
display: grid;
gap: 1px;
min-width: 0;
}
.address-tree-node-content strong,
.address-tree-node-content small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-tree-node-content small {
color: var(--muted);
font-size: 0.78rem;
}
.address-tree-summary {
border-top: var(--border-line);
flex: 0 0 auto;
padding: 12px;
}
.address-source-summary {
display: grid;
gap: 10px;
}
.address-source-subsummary {
border-top: var(--border-line);
display: grid;
gap: 8px;
padding-top: 10px;
}
.address-source-summary p {
color: var(--muted);
margin: 4px 0 0;
}
.address-sync-dialog {
max-width: min(960px, calc(100vw - 36px));
width: min(960px, calc(100vw - 36px));
}
.address-sync-result-list,
.address-sync-record-list,
.address-sync-plan-grid {
border: var(--border-line);
display: grid;
max-height: 260px;
overflow: auto;
}
.address-sync-result-row,
.address-sync-record-row,
.address-sync-plan-row {
align-items: center;
background: transparent;
border: 0;
border-bottom: var(--border-line);
color: var(--text);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 9px 10px;
text-align: left;
}
.address-sync-result-row {
cursor: pointer;
}
.address-sync-result-row:hover,
.address-sync-result-row:focus-visible {
background: var(--line);
outline: none;
}
.address-sync-result-row strong,
.address-sync-result-row small,
.address-sync-record-row strong,
.address-sync-record-row small,
.address-sync-plan-row span,
.address-sync-plan-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-sync-result-row small,
.address-sync-record-row small,
.address-sync-plan-row small {
color: var(--muted);
display: block;
}
.address-sync-plan-row {
grid-template-columns: auto minmax(0, 1fr);
}
.address-sync-source-card,
.address-sync-inspector {
display: grid;
gap: 12px;
}
.address-conflict-review {
display: grid;
gap: 14px;
}
.address-conflict-grid {
border: var(--border-line);
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
max-height: min(52vh, 520px);
overflow: auto;
}
.address-conflict-grid-header,
.address-conflict-row > * {
border-bottom: var(--border-line);
min-width: 0;
padding: 9px 10px;
}
.address-conflict-grid-header {
background: var(--line);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.address-conflict-row {
display: contents;
}
.address-conflict-row.has-difference > * {
background: var(--surface-subtle);
}
.address-conflict-row span {
overflow-wrap: anywhere;
}
.address-conflict-choice {
align-items: center;
display: inline-flex;
gap: 0;
white-space: nowrap;
}
.address-conflict-choice button {
background: var(--panel);
border: var(--border-line);
color: var(--text);
cursor: pointer;
font: inherit;
min-height: 28px;
padding: 3px 8px;
}
.address-conflict-choice button:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.address-conflict-choice button:last-child {
border-left: 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.address-conflict-choice button.is-selected {
background: var(--line);
box-shadow: inset 0 1px 2px rgba(var(--shadow-color-rgb), .12);
}
.address-list-panel,
.address-detail-panel {
display: flex;
flex-direction: column;
}
.address-list-panel {
border-right: var(--border-line);
}
.address-panel-header {
align-items: flex-start;
border-bottom: var(--border-line);
display: flex;
gap: 12px;
justify-content: space-between;
padding: 13px 14px;
}
.address-panel-header h2,
.address-detail-header h2 {
font-size: 1.05rem;
margin: 0;
}
.address-panel-header p,
.address-detail-header p {
color: var(--muted);
margin: 4px 0 0;
}
.address-contact-toolbar {
align-items: center;
display: flex;
gap: 10px;
border-bottom: var(--border-line);
padding: 10px 12px;
}
.address-contact-toolbar input {
flex: 1 1 auto;
}
.address-contact-list {
display: grid;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 8px;
align-content: start;
}
.address-contact-row {
align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
padding: 10px;
text-align: left;
}
.address-contact-row[draggable="true"] {
cursor: grab;
}
.address-contact-row[draggable="true"]:active {
cursor: grabbing;
}
.address-contact-row:hover,
.address-contact-row:focus-visible,
.address-contact-row.is-selected {
background: var(--line);
outline: none;
}
.address-contact-row-main {
display: grid;
gap: 2px;
min-width: 0;
}
.address-contact-row-main strong,
.address-contact-row-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-contact-row-main small {
color: var(--muted);
}
.address-contact-row-meta {
align-items: center;
display: inline-flex;
gap: 6px;
justify-content: flex-end;
min-width: 0;
}
.address-tag {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 999px;
color: var(--muted);
display: inline-flex;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
max-width: 110px;
overflow: hidden;
padding: 4px 7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-detail-panel {
overflow: auto;
padding: 16px;
}
.address-contact-detail {
display: grid;
gap: 16px;
}
.address-detail-header {
align-items: flex-start;
display: flex;
gap: 14px;
justify-content: space-between;
}
.address-detail-section {
border-top: var(--border-line);
display: grid;
gap: 8px;
padding-top: 12px;
}
.address-detail-section h3 {
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.05em;
margin: 0;
text-transform: uppercase;
}
.address-detail-list {
display: grid;
gap: 8px;
margin: 0;
}
.address-detail-list div {
display: grid;
gap: 2px;
}
.address-detail-list dt {
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
}
.address-detail-list dd {
margin: 0;
overflow-wrap: anywhere;
}
.address-membership-row {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
}
.address-membership-list {
display: grid;
gap: 8px;
}
.address-membership-row p {
margin: 0;
}
.address-detail-empty,
.address-empty-note {
color: var(--muted);
}
.address-detail-empty {
align-content: center;
display: grid;
height: 100%;
justify-items: center;
padding: 24px;
text-align: center;
}
.address-detail-empty p,
.address-empty-note {
margin: 6px 0 0;
}
.address-dialog-form {
display: grid;
gap: 14px;
}
.dialog-panel.address-contact-dialog {
width: min(1120px, calc(100vw - 40px));
max-width: calc(100vw - 40px);
}
.dialog-panel.address-member-dialog {
width: min(720px, calc(100vw - 40px));
max-width: calc(100vw - 40px);
}
.address-member-picker {
display: grid;
gap: 12px;
}
.address-member-search {
border-bottom: 0;
padding: 0;
}
.address-member-candidate-list {
border: var(--border-line);
border-radius: 6px;
display: grid;
max-height: min(460px, calc(100vh - 300px));
overflow: auto;
}
.address-member-candidate {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.7fr) auto;
min-width: 0;
padding: 10px 12px;
}
.address-member-candidate select {
min-width: 0;
}
.address-member-candidate:last-child {
border-bottom: 0;
}
.address-member-candidate:hover {
background: var(--panel-soft);
}
.address-vcard-textarea {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
min-height: 280px;
}
.address-form-section {
border: var(--border-line);
border-radius: 6px;
display: grid;
gap: 10px;
padding: 12px;
}
.address-form-section-heading,
.address-form-row {
align-items: center;
display: grid;
gap: 8px;
}
.address-form-section-heading {
grid-template-columns: minmax(0, 1fr) auto;
}
.address-form-list {
display: grid;
gap: 8px;
}
.address-form-row-email,
.address-form-row-phone {
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
}
.address-form-row-postal {
grid-template-columns: 92px minmax(80px, 0.2fr) minmax(180px, 1fr) minmax(90px, 0.35fr) minmax(120px, 0.45fr) minmax(110px, 0.4fr) minmax(120px, 0.45fr) 34px;
}
.address-form-row > *,
.address-form-row input,
.address-form-row select,
.address-form-row textarea {
min-width: 0;
}
.address-primary-choice {
align-items: center;
color: var(--muted);
display: inline-flex;
font-size: 0.86rem;
gap: 6px;
}
.address-form-row .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.inline-actions {
justify-content: flex-end;
}
.inline-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.strong-link {
font-weight: 700;
}
.link-button {
background: transparent;
border: 0;
color: var(--text-strong);
cursor: pointer;
font: inherit;
padding: 0;
text-align: left;
}
.link-button:hover {
color: var(--accent);
text-decoration: underline;
}
.is-selected-row {
background: var(--panel-soft);
}
.address-book-page .is-archived-row {
opacity: 0.72;
}
@media (max-width: 980px) {
.address-book-workspace,
.form-grid.two,
.form-grid.three,
.form-grid.four,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {
grid-template-columns: 1fr;
}
}