Dateien
TaskMate/frontend/js/contacts.js
Server Deploy 4bd57d653f UI-Redesign: AegisSight Design, Filter-Popover, Header-Umbau
- Session-Timeout auf 60 Minuten erhöht (ACCESS_TOKEN_EXPIRY + SESSION_TIMEOUT)
- AegisSight Light Theme: Gold-Akzent (#C8A851) statt Indigo
- Navigation-Tabs in eigene Zeile unter Header verschoben (HTML-Struktur)
- Filter-Bar durch kompaktes Popover mit Checkboxen ersetzt (Mehrfachauswahl)
- Archiv-Funktion repariert (lädt jetzt per API statt leerem Store)
- Filter-Bugs behoben: Reset-Button ID, Default-Werte, Ohne-Datum-Filter
- Mehrspalten-Layout Feature entfernt
- Online-Status vom Header an User-Avatar verschoben (grüner Punkt)
- Lupen-Icon entfernt
- CLAUDE.md: Docker-Deploy und CSS-Tricks Regeln aktualisiert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:49:38 +01:00

1397 Zeilen
49 KiB
JavaScript

/**
* TASKMATE - Kontaktmanagement
* =============================
* Frontend-Modul für Kontakte
* VERSION: 332-fixed-api-paths
*/
import { api } from './api.js';
import store from './store.js';
import { $, $$, createElement, formatDate, debounce } from './utils.js';
class ContactsManager {
constructor() {
this.container = null;
this.currentView = 'list'; // list, detail, form
this.currentContact = null;
this.searchQuery = '';
this.activeFilter = 'all'; // all, person, company
this.isInitialized = false;
// Bind methods
this.handleSearch = debounce(this.handleSearch.bind(this), 300);
this.handleContactClick = this.handleContactClick.bind(this);
this.handleAddContact = this.handleAddContact.bind(this);
this.handleFilterChange = this.handleFilterChange.bind(this);
}
async init() {
console.log('[Contacts] Initializing... VERSION: 332-fixed-api-paths');
this.container = $('#view-contacts');
if (!this.container) {
console.error('[Contacts] Container not found');
return;
}
// Initial setup
this.renderLayout();
// Only load contacts if user is authenticated and project is selected
const currentProject = store.getCurrentProject();
if (currentProject && currentProject.id) {
await this.loadContacts();
} else {
console.warn('[Contacts] Waiting for project selection before loading contacts');
}
// Subscribe to store updates
this.subscribeToUpdates();
this.isInitialized = true;
console.log('[Contacts] Initialized');
}
renderLayout() {
this.container.innerHTML = `
<div class="contacts-layout">
<!-- Hauptbereich ohne Sidebar -->
<main class="contacts-main full-width">
<div class="contacts-header">
<div class="header-left">
<h2>Kontakte</h2>
<span class="contact-count">0 Kontakte</span>
</div>
<div class="header-center">
<!-- Filter Buttons -->
<div class="filter-buttons">
<button class="filter-btn ${this.activeFilter === 'all' ? 'active' : ''}" data-filter="all">
Alle <span class="filter-badge" data-filter-count="all">0</span>
</button>
<button class="filter-btn ${this.activeFilter === 'person' ? 'active' : ''}" data-filter="person" title="Personen">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span class="filter-badge" data-filter-count="person">0</span>
</button>
<button class="filter-btn ${this.activeFilter === 'company' ? 'active' : ''}" data-filter="company" title="Institutionen">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
<path d="M9 9v.01"/>
<path d="M9 12v.01"/>
<path d="M9 15v.01"/>
<path d="M9 18v.01"/>
</svg>
<span class="filter-badge" data-filter-count="company">0</span>
</button>
</div>
</div>
<div class="header-actions">
<button class="btn btn-primary" id="add-person-btn">
<span style="font-weight: bold; margin-right: 4px;">+</span>
Neuer Kontakt
</button>
<button class="btn btn-primary" id="add-company-btn">
<span style="font-weight: bold; margin-right: 4px;">+</span>
Neue Institution
</button>
</div>
</div>
<div class="contacts-content" id="contacts-content">
<!-- Wird dynamisch gefüllt -->
</div>
</main>
</div>
`;
// Bind events
this.bindEvents();
}
bindEvents() {
// Add person button
const addPersonBtn = this.container.querySelector('#add-person-btn');
if (addPersonBtn) {
addPersonBtn.replaceWith(addPersonBtn.cloneNode(true));
const newAddPersonBtn = this.container.querySelector('#add-person-btn');
newAddPersonBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showContactForm(null, 'person');
});
}
// Add company/organization button
const addCompanyBtn = this.container.querySelector('#add-company-btn');
if (addCompanyBtn) {
addCompanyBtn.replaceWith(addCompanyBtn.cloneNode(true));
const newAddCompanyBtn = this.container.querySelector('#add-company-btn');
newAddCompanyBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showContactForm(null, 'company');
});
}
// Filter button clicks
const filterButtons = this.container.querySelectorAll('.filter-btn');
filterButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const filter = e.currentTarget.dataset.filter;
this.handleFilterChange({ target: { value: filter } });
});
});
}
async loadContacts() {
try {
const currentProject = store.getCurrentProject();
if (!currentProject || !currentProject.id) {
console.warn('[Contacts] No current project available');
return;
}
const params = new URLSearchParams({
project_id: currentProject.id
});
// Suche wird serverseitig gefiltert
if (this.searchQuery) {
params.append('search', this.searchQuery);
}
// NICHT mehr serverseitig filtern - alle Kontakte laden
// Filter wird jetzt clientseitig in getFilteredContacts() angewendet
const response = await api.request(`/contacts?${params}`);
if (response.success) {
this.allContacts = response.data; // Alle Kontakte speichern
this.updateCounts();
this.renderContacts();
}
} catch (error) {
console.error('[Contacts] Error loading contacts:', error);
this.showError('Fehler beim Laden der Kontakte');
}
}
getFilteredContacts() {
if (!this.allContacts) return [];
if (this.activeFilter === 'all') {
return this.allContacts;
}
return this.allContacts.filter(c => c.type === this.activeFilter);
}
updateCounts() {
const contacts = this.allContacts || [];
// Gesamtzahl (zeigt gefilterte Anzahl)
const filteredContacts = this.getFilteredContacts();
const totalCount = this.container.querySelector('.contact-count');
if (totalCount) {
totalCount.textContent = `${filteredContacts.length} Kontakte`;
}
// Filter-Counts (zeigt immer alle Zahlen korrekt)
const counts = {
all: contacts.length,
person: contacts.filter(c => c.type === 'person').length,
company: contacts.filter(c => c.type === 'company').length
};
Object.entries(counts).forEach(([filter, count]) => {
const element = this.container.querySelector(`[data-filter-count="${filter}"]`);
if (element) {
element.textContent = count;
}
});
}
renderContacts() {
const content = this.container.querySelector('#contacts-content');
if (!content) return;
const contacts = this.getFilteredContacts();
if (contacts.length === 0) {
content.innerHTML = `
<div class="empty-state">
<svg class="empty-icon" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<h3>Keine Kontakte vorhanden</h3>
<p>Erstellen Sie Ihren ersten Kontakt</p>
</div>
`;
return;
}
// Gruppiere Kontakte alphabetisch
const grouped = this.groupContactsByLetter(contacts);
content.innerHTML = `
<div class="contacts-list">
${Object.entries(grouped).map(([letter, contacts]) => `
<div class="contact-group">
<h3 class="group-letter">${letter}</h3>
<div class="contact-cards">
${contacts.map(contact => this.renderContactCard(contact)).join('')}
</div>
</div>
`).join('')}
</div>
`;
// Bind click events
content.querySelectorAll('.contact-card').forEach(card => {
card.addEventListener('click', () => {
const contactId = card.dataset.contactId;
this.handleContactClick(contactId);
});
});
}
groupContactsByLetter(contacts) {
const grouped = {};
contacts.forEach(contact => {
const firstLetter = (contact.display_name || '?')[0].toUpperCase();
if (!grouped[firstLetter]) {
grouped[firstLetter] = [];
}
grouped[firstLetter].push(contact);
});
// Sortiere die Gruppen
return Object.keys(grouped)
.sort()
.reduce((result, key) => {
result[key] = grouped[key];
return result;
}, {});
}
renderContactCard(contact) {
const primaryEmail = contact.details?.find(d => d.type === 'email' && d.is_primary) ||
contact.details?.find(d => d.type === 'email');
const primaryPhone = contact.details?.find(d => d.type === 'phone' && d.is_primary) ||
contact.details?.find(d => d.type === 'phone');
// Icons für Avatar basierend auf Kontakttyp
const avatarIcon = contact.type === 'person'
? `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>`
: `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
<path d="M9 9v.01"/>
<path d="M9 12v.01"/>
<path d="M9 15v.01"/>
<path d="M9 18v.01"/>
</svg>`;
return `
<div class="contact-card" data-contact-id="${contact.id}">
<div class="contact-avatar" style="background-color: ${this.getAvatarColor(contact)}">
${contact.avatar_url
? `<img src="${contact.avatar_url}" alt="${contact.display_name}">`
: avatarIcon
}
</div>
<div class="contact-info">
<h4 class="contact-name">${contact.display_name}</h4>
${contact.type === 'person' && contact.position ?
`<p class="contact-position">${contact.position}
${contact.parent_company_name ? ` bei ${contact.parent_company_name}` : ''}
</p>` : ''
}
${contact.type === 'company' && contact.industry ?
`<p class="contact-industry">${contact.industry}</p>` : ''
}
<div class="contact-details">
${primaryEmail ?
`<span class="detail-item">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<path d="M22 6l-10 7L2 6"/>
</svg>
${primaryEmail.value}
</span>` : ''
}
${primaryPhone ?
`<span class="detail-item">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
${primaryPhone.value}
</span>` : ''
}
</div>
</div>
</div>
`;
}
getInitials(contact) {
if (contact.type === 'person') {
const first = contact.first_name?.[0] || '';
const last = contact.last_name?.[0] || '';
return (first + last).toUpperCase() || '?';
} else {
return contact.company_name?.[0]?.toUpperCase() || '?';
}
}
getAvatarColor(contact) {
// Generiere eine konsistente Farbe basierend auf dem Namen
const name = contact.display_name || '';
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
'#3B82F6', '#EF4444', '#10B981', '#F59E0B',
'#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'
];
return colors[Math.abs(hash) % colors.length];
}
async handleContactClick(contactId) {
try {
const response = await api.request(`/contacts/${contactId}`);
if (response.success) {
this.showContactDetail(response.data);
}
} catch (error) {
console.error('[Contacts] Error loading contact:', error);
this.showError('Fehler beim Laden des Kontakts');
}
}
showContactDetail(contact) {
console.log('[Contacts] showContactDetail called with:', contact);
this.currentContact = contact;
this.currentView = 'detail';
const content = this.container.querySelector('#contacts-content');
if (!content) return;
const initials = this.getInitials(contact);
content.innerHTML = `
<div class="contact-detail">
<div class="detail-header">
<button class="btn-back" id="back-to-list">
<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Zurück
</button>
<div class="detail-actions">
<button class="btn btn-secondary" id="edit-contact">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Bearbeiten
</button>
<button class="btn btn-danger" id="delete-contact">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
</svg>
Löschen
</button>
</div>
</div>
<div class="detail-content">
<div class="detail-main">
<div class="contact-header-detail">
<div class="avatar-large" style="background-color: ${this.getAvatarColor(contact)}">
${contact.avatar_url
? `<img src="${contact.avatar_url}" alt="${contact.display_name}">`
: `<span>${initials}</span>`
}
</div>
<div class="contact-title">
<h1>${contact.display_name}</h1>
${contact.type === 'person' && contact.position ?
`<p class="subtitle">${contact.position}
${contact.parent_company_name ? ` bei ${contact.parent_company_name}` : ''}
</p>` : ''
}
${contact.type === 'company' && contact.industry ?
`<p class="subtitle">${contact.industry}</p>` : ''
}
</div>
</div>
<!-- Kontaktdetails -->
<section class="detail-section">
<h3>Kontaktdaten</h3>
<div class="details-grid">
${this.renderContactDetails(contact.details)}
</div>
</section>
<!-- Weitere Informationen -->
${this.renderAdditionalInfo(contact)}
<!-- Notizen -->
${contact.notes ? `
<section class="detail-section">
<h3>Notizen</h3>
<div class="notes-content">${contact.notes}</div>
</section>
` : ''}
<!-- Interaktionen -->
<section class="detail-section">
<div class="section-header">
<h3>Aktivitäten</h3>
<button class="btn btn-sm" id="add-interaction">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Hinzufügen
</button>
</div>
<div class="interactions-list">
${this.renderInteractions(contact.interactions)}
</div>
</section>
</div>
<aside class="detail-sidebar">
<!-- Meta-Informationen -->
<div class="sidebar-section">
<h4>Informationen</h4>
<dl class="meta-list">
<dt>Erstellt am</dt>
<dd>${formatDate(contact.created_at)}</dd>
<dt>Erstellt von</dt>
<dd>${contact.created_by_name || 'System'}</dd>
${contact.updated_at !== contact.created_at ? `
<dt>Aktualisiert</dt>
<dd>${formatDate(contact.updated_at)}</dd>
` : ''}
</dl>
</div>
</aside>
</div>
</div>
`;
// Bind detail events
this.bindDetailEvents();
}
renderContactDetails(details) {
if (!details || details.length === 0) {
return '<p class="text-muted">Keine Kontaktdaten vorhanden</p>';
}
const getIconSvg = (type) => {
const icons = {
email: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><path d="M22 6l-10 7L2 6"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>',
mobile: '<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><path d="M12 18h.01"/>',
fax: '<path d="M6 9V2h12v7"/><rect x="6" y="9" width="12" height="12"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><circle cx="18" cy="12" r="1"/>',
address: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
social: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'
};
return icons[type] || '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>';
};
return details.map(detail => `
<div class="detail-item">
<svg class="detail-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${getIconSvg(detail.type)}
</svg>
<div class="detail-content">
<span class="detail-label">${detail.label}</span>
<span class="detail-value">${detail.value}</span>
</div>
</div>
`).join('');
}
renderAdditionalInfo(contact) {
const sections = [];
if (contact.type === 'person') {
const fields = [];
if (contact.salutation) fields.push(['Anrede', contact.salutation]);
if (contact.department) fields.push(['Abteilung', contact.department]);
if (fields.length > 0) {
sections.push(`
<section class="detail-section">
<h3>Person</h3>
<dl class="info-list">
${fields.map(([label, value]) => `
<dt>${label}</dt>
<dd>${value}</dd>
`).join('')}
</dl>
</section>
`);
}
}
if (contact.type === 'company') {
const fields = [];
if (contact.company_type) fields.push(['Typ', contact.company_type]);
if (contact.website) fields.push(['Website', `<a href="${contact.website}" target="_blank">${contact.website}</a>`]);
if (fields.length > 0) {
sections.push(`
<section class="detail-section">
<h3>Institution</h3>
<dl class="info-list">
${fields.map(([label, value]) => `
<dt>${label}</dt>
<dd>${value}</dd>
`).join('')}
</dl>
</section>
`);
}
}
return sections.join('');
}
renderInteractions(interactions) {
if (!interactions || interactions.length === 0) {
return '<p class="text-muted">Noch keine Aktivitäten vorhanden</p>';
}
const getIconSvg = (type) => {
const icons = {
call: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>',
email: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><path d="M22 6l-10 7L2 6"/>',
meeting: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
note: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
task: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>'
};
return icons[type] || '<circle cx="12" cy="12" r="10"/>';
};
const typeLabels = {
call: 'Anruf',
email: 'E-Mail',
meeting: 'Treffen',
note: 'Notiz',
task: 'Aufgabe'
};
return interactions.map(interaction => `
<div class="interaction-item">
<div class="interaction-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${getIconSvg(interaction.type)}
</svg>
</div>
<div class="interaction-content">
<div class="interaction-header">
<span class="interaction-type">${typeLabels[interaction.type]}</span>
<span class="interaction-date">${formatDate(interaction.interaction_date)}</span>
</div>
${interaction.subject ? `<h4>${interaction.subject}</h4>` : ''}
${interaction.content ? `<p>${interaction.content}</p>` : ''}
<span class="interaction-author">von ${interaction.created_by_name}</span>
</div>
</div>
`).join('');
}
bindDetailEvents() {
// Back button
const backBtn = this.container.querySelector('#back-to-list');
if (backBtn) {
backBtn.addEventListener('click', () => {
this.currentView = 'list';
this.renderContacts();
});
}
// Edit button
const editBtn = this.container.querySelector('#edit-contact');
if (editBtn) {
editBtn.addEventListener('click', () => {
this.showContactForm(this.currentContact);
});
}
// Delete button
const deleteBtn = this.container.querySelector('#delete-contact');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
this.handleDeleteContact(this.currentContact);
});
}
// Add interaction
const addInteractionBtn = this.container.querySelector('#add-interaction');
if (addInteractionBtn) {
addInteractionBtn.addEventListener('click', () => {
this.showInteractionModal();
});
}
}
showContactForm(contact = null, defaultType = 'person') {
const isEdit = !!contact;
const contactType = contact?.type || defaultType;
this.currentView = 'form';
try {
// Erstelle Overlay
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.setAttribute('data-close-modal', '');
document.body.appendChild(overlay);
// Erstelle Modal-Container
const modal = document.createElement('div');
modal.className = 'modal modal-large';
// Titel basierend auf Typ und Edit-Modus
const formTitle = isEdit
? 'Kontakt bearbeiten'
: (contactType === 'company' ? 'Neue Institution' : 'Neuer Kontakt');
// Build HTML string first
const modalContent = `
<div class="form-header">
<h2>${formTitle}</h2>
<button type="button" class="btn-close modal-close" data-close-modal>
<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form id="contact-form" class="contact-form">
<!-- Kontakttyp -->
<div class="form-section">
<label class="form-label">Kontakttyp</label>
<div class="radio-group">
<label class="radio-item">
<input type="radio" name="type" value="person"
${contactType === 'person' ? 'checked' : ''}>
<span>Person</span>
</label>
<label class="radio-item">
<input type="radio" name="type" value="company"
${contactType === 'company' ? 'checked' : ''}>
<span>Institution</span>
</label>
</div>
</div>
<!-- Dynamische Felder basierend auf Typ -->
<div id="type-specific-fields">
${this.renderTypeSpecificFields(contactType, contact)}
</div>
<!-- Kontaktdetails -->
<div class="form-section">
<h3>Kontaktdaten</h3>
<div id="contact-details-list" class="details-list">
${contact?.details ?
contact.details.map((d, i) => this.renderDetailField(d, i)).join('') :
this.renderDetailField(null, 0)
}
</div>
<button type="button" class="btn btn-secondary btn-sm" id="add-detail">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
Kontaktdaten hinzufügen
</button>
</div>
<!-- Notizen -->
<div class="form-section">
<label class="form-label">Notizen</label>
<textarea name="notes" class="form-control" rows="4"
placeholder="Zusätzliche Informationen...">${contact?.notes || ''}</textarea>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-form">
Abbrechen
</button>
<button type="button" class="btn btn-primary" id="submit-form">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
`;
console.log('[Contacts] Setting modal innerHTML...');
modal.innerHTML = modalContent;
console.log('[Contacts] Modal innerHTML set');
document.body.appendChild(modal);
// Store overlay reference for cleanup
modal._overlay = overlay;
// Wait for DOM to be ready
requestAnimationFrame(() => {
console.log('[Contacts] Adding visible classes...');
overlay.classList.add('visible');
modal.classList.add('visible');
// Double wait to ensure rendering is complete
requestAnimationFrame(() => {
console.log('[Contacts] Binding form events...');
this.bindFormEvents(contact, modal);
});
});
} catch (error) {
console.error('[Contacts] Error showing contact form:', error);
this.showError('Fehler beim Öffnen des Formulars');
}
}
renderTypeSpecificFields(type, contact = null) {
if (type === 'person') {
return `
<div class="form-row">
<div class="form-group">
<label class="form-label">Anrede</label>
<select name="salutation" class="form-control">
<option value="">-- Keine --</option>
<option value="Herr" ${contact?.salutation === 'Herr' ? 'selected' : ''}>Herr</option>
<option value="Frau" ${contact?.salutation === 'Frau' ? 'selected' : ''}>Frau</option>
<option value="Dr." ${contact?.salutation === 'Dr.' ? 'selected' : ''}>Dr.</option>
<option value="Prof." ${contact?.salutation === 'Prof.' ? 'selected' : ''}>Prof.</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Vorname</label>
<input type="text" name="first_name" class="form-control"
value="${contact?.first_name || ''}" placeholder="Max">
</div>
<div class="form-group">
<label class="form-label">Nachname <span class="required">*</span></label>
<input type="text" name="last_name" class="form-control"
value="${contact?.last_name || ''}" placeholder="Mustermann" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Position</label>
<input type="text" name="position" class="form-control"
value="${contact?.position || ''}" placeholder="Geschäftsführer">
</div>
<div class="form-group">
<label class="form-label">Abteilung</label>
<input type="text" name="department" class="form-control"
value="${contact?.department || ''}" placeholder="Vertrieb">
</div>
<div class="form-group">
<label class="form-label">Institution</label>
<select name="parent_company_id" class="form-control">
<option value="">-- Keine Institution --</option>
${this.getCompanyOptions(contact?.parent_company_id)}
</select>
</div>
</div>
`;
} else {
return `
<div class="form-row">
<div class="form-group full-width">
<label class="form-label">Name <span class="required">*</span></label>
<input type="text" name="company_name" class="form-control"
value="${contact?.company_name || ''}" placeholder="Musterinstitution GmbH" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Institutionstyp</label>
<input type="text" name="company_type" class="form-control"
value="${contact?.company_type || ''}" placeholder="GmbH, e.V., etc.">
</div>
<div class="form-group">
<label class="form-label">Branche</label>
<input type="text" name="industry" class="form-control"
value="${contact?.industry || ''}" placeholder="IT">
</div>
<div class="form-group">
<label class="form-label">Website</label>
<input type="url" name="website" class="form-control"
value="${contact?.website || ''}" placeholder="https://example.com">
</div>
</div>
`;
}
}
getCompanyOptions(selectedId = null) {
// Filter nur Firmen aus den geladenen Kontakten
const companies = this.allContacts?.filter(c => c.type === 'company') || [];
return companies.map(company => `
<option value="${company.id}" ${company.id === selectedId ? 'selected' : ''}>
${company.display_name}
</option>
`).join('');
}
renderDetailField(detail, index) {
const types = {
email: 'E-Mail',
phone: 'Telefon',
mobile: 'Mobil',
fax: 'Fax',
address: 'Adresse',
social: 'Social Media'
};
const labels = {
email: ['Geschäftlich', 'Privat', 'Andere'],
phone: ['Arbeit', 'Privat', 'Andere'],
mobile: ['Arbeit', 'Privat', 'Andere'],
fax: ['Arbeit', 'Privat'],
address: ['Geschäft', 'Privat', 'Andere'],
social: ['LinkedIn', 'Twitter', 'Facebook', 'Instagram', 'XING', 'Andere']
};
return `
<div class="detail-field" data-index="${index}">
<div class="detail-controls">
<select name="details[${index}][type]" class="form-control detail-type">
${Object.entries(types).map(([value, label]) => `
<option value="${value}" ${detail?.type === value ? 'selected' : ''}>
${label}
</option>
`).join('')}
</select>
<select name="details[${index}][label]" class="form-control detail-label">
${(labels[detail?.type || 'email'] || labels.email).map(label => `
<option value="${label}" ${detail?.label === label ? 'selected' : ''}>
${label}
</option>
`).join('')}
</select>
<input type="text" name="details[${index}][value]" class="form-control detail-value"
value="${detail?.value || ''}" placeholder="Wert eingeben..." required>
<label class="checkbox-primary">
<input type="checkbox" name="details[${index}][is_primary]"
${detail?.is_primary ? 'checked' : ''}>
<span>Primär</span>
</label>
<button type="button" class="btn-icon btn-remove-detail" data-index="${index}">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
</svg>
</button>
</div>
</div>
`;
}
bindFormEvents(existingContact = null, modal = null) {
console.log('[Contacts] bindFormEvents called');
// Try with modal context first
const form = modal ? modal.querySelector('#contact-form') : document.querySelector('#contact-form');
if (!form) {
console.error('[Contacts] Form not found!');
return;
}
console.log('[Contacts] Form found, binding events...');
// Double-check DOM is ready
const buttons = form.querySelectorAll('button');
console.log('[Contacts] Buttons found:', buttons.length);
// List all buttons for debugging
buttons.forEach((btn, idx) => {
console.log(`[Contacts] Button ${idx}:`, btn.id, btn.className, btn.textContent.trim());
});
// Type change
const typeInputs = form.querySelectorAll('input[name="type"]');
typeInputs.forEach(input => {
input.addEventListener('change', (e) => {
const fieldsContainer = form.querySelector('#type-specific-fields');
if (fieldsContainer) {
fieldsContainer.innerHTML = this.renderTypeSpecificFields(e.target.value);
}
});
});
// Detail type change
form.addEventListener('change', (e) => {
if (e.target.classList.contains('detail-type')) {
const index = e.target.closest('.detail-field').dataset.index;
const labelSelect = form.querySelector(`select[name="details[${index}][label]"]`);
// Update label options based on type
const labels = {
email: ['Geschäftlich', 'Privat', 'Andere'],
phone: ['Arbeit', 'Privat', 'Andere'],
mobile: ['Arbeit', 'Privat', 'Andere'],
fax: ['Arbeit', 'Privat'],
address: ['Geschäft', 'Privat', 'Andere'],
social: ['LinkedIn', 'Twitter', 'Facebook', 'Instagram', 'XING', 'Andere']
};
const options = (labels[e.target.value] || labels.email).map(label =>
`<option value="${label}">${label}</option>`
).join('');
labelSelect.innerHTML = options;
}
});
// Add detail
const addDetailBtn = form.querySelector('#add-detail');
if (addDetailBtn) {
addDetailBtn.addEventListener('click', () => {
const detailsList = form.querySelector('#contact-details-list');
const currentCount = detailsList.querySelectorAll('.detail-field').length;
const newField = document.createElement('div');
newField.innerHTML = this.renderDetailField(null, currentCount);
detailsList.appendChild(newField.firstElementChild);
});
}
// Remove detail
form.addEventListener('click', (e) => {
if (e.target.closest('.btn-remove-detail')) {
const field = e.target.closest('.detail-field');
field.remove();
// Reindex remaining fields
form.querySelectorAll('.detail-field').forEach((field, index) => {
field.dataset.index = index;
field.querySelectorAll('[name^="details["]').forEach(input => {
input.name = input.name.replace(/\[\d+\]/, `[${index}]`);
});
});
}
});
// Close form
const closeModal = () => {
if (modal && modal._overlay) {
modal.classList.remove('visible');
modal._overlay.classList.remove('visible');
setTimeout(() => {
modal.remove();
modal._overlay.remove();
}, 300);
}
if (existingContact) {
this.showContactDetail(existingContact);
} else {
this.currentView = 'list';
this.renderContacts();
}
};
// Modal close handlers
if (modal) {
modal.querySelectorAll('[data-close-modal]').forEach(btn => {
btn.addEventListener('click', closeModal);
});
// Overlay click to close
if (modal._overlay) {
modal._overlay.addEventListener('click', closeModal);
}
}
// Use event delegation for buttons
form.addEventListener('click', async (e) => {
// Check if click was on submit button
if (e.target.id === 'submit-form' || e.target.classList.contains('btn-primary')) {
console.log('[Contacts] Submit button clicked via delegation');
e.preventDefault();
e.stopPropagation();
try {
await this.handleFormSubmit(form, existingContact, modal);
} catch (error) {
console.error('[Contacts] Form submit error:', error);
}
}
// Check if click was on cancel button
if (e.target.id === 'cancel-form') {
console.log('[Contacts] Cancel button clicked');
e.preventDefault();
closeModal();
}
});
// Prevent form submit just in case
form.addEventListener('submit', (e) => {
console.log('[Contacts] Form submit prevented');
e.preventDefault();
return false;
});
}
async handleFormSubmit(form, existingContact = null, modal = null) {
console.log('[Contacts] handleFormSubmit called');
const formData = new FormData(form);
const currentProject = store.getCurrentProject();
if (!currentProject || !currentProject.id) {
this.showError('Kein Projekt ausgewählt');
return;
}
const data = {
type: formData.get('type'),
project_id: currentProject.id,
details: []
};
// Type-specific fields
if (data.type === 'person') {
data.salutation = formData.get('salutation') || null;
data.first_name = formData.get('first_name') || null;
data.last_name = formData.get('last_name') || null;
data.position = formData.get('position') || null;
data.department = formData.get('department') || null;
data.birth_date = formData.get('birth_date') || null;
data.parent_company_id = formData.get('parent_company_id') || null;
// Validierung für Person
if (!data.last_name) {
this.showError('Nachname ist erforderlich');
return;
}
// Generate display_name
data.display_name = `${data.first_name || ''} ${data.last_name || ''}`.trim();
} else {
data.company_name = formData.get('company_name') || null;
data.company_type = formData.get('company_type') || null;
data.industry = formData.get('industry') || null;
data.website = formData.get('website') || null;
// Validierung für Firma
if (!data.company_name) {
this.showError('Firmenname ist erforderlich');
return;
}
// Set display_name for company
data.display_name = data.company_name;
}
// Common fields
data.notes = formData.get('notes') || null;
// Details
const detailFields = form.querySelectorAll('.detail-field');
detailFields.forEach((field, index) => {
const type = form.querySelector(`select[name="details[${index}][type]"]`)?.value;
const label = form.querySelector(`select[name="details[${index}][label]"]`)?.value;
const value = form.querySelector(`input[name="details[${index}][value]"]`)?.value;
const isPrimary = form.querySelector(`input[name="details[${index}][is_primary]"]`)?.checked;
if (value) {
data.details.push({
type,
label,
value,
is_primary: isPrimary
});
}
});
try {
const isEdit = !!existingContact;
const url = isEdit
? `/contacts/${existingContact.id}`
: '/contacts';
const method = isEdit ? 'PUT' : 'POST';
console.log('[Contacts] Sending request:', method, url, data);
const response = await api.request(url, {
method: method,
body: data
});
console.log('[Contacts] API Response:', response);
if (response.success) {
this.showSuccess(response.message || `Kontakt erfolgreich ${isEdit ? 'aktualisiert' : 'erstellt'}`);
// Close modal with animation
if (modal && modal._overlay) {
modal.classList.remove('visible');
modal._overlay.classList.remove('visible');
setTimeout(() => {
modal.remove();
modal._overlay.remove();
}, 300);
}
await this.loadContacts();
// Zur Detailansicht wechseln
if (response.data && response.data.id) {
console.log('[Contacts] Loading full contact details for ID:', response.data.id);
// Load full contact details
try {
const detailResponse = await api.request(`/contacts/${response.data.id}`);
if (detailResponse.success && detailResponse.data) {
this.showContactDetail(detailResponse.data);
} else {
console.error('[Contacts] Could not load contact details');
await this.loadContacts();
}
} catch (error) {
console.error('[Contacts] Error loading contact details:', error);
await this.loadContacts();
}
} else {
console.error('[Contacts] No data in response!');
await this.loadContacts();
}
}
} catch (error) {
console.error('[Contacts] Error saving contact:', error);
this.showError(error.message || 'Fehler beim Speichern des Kontakts');
}
}
async handleDeleteContact(contact) {
if (!confirm(`Möchten Sie den Kontakt "${contact.display_name}" wirklich löschen?`)) {
return;
}
try {
const response = await api.request(`/contacts/${contact.id}`, {
method: 'DELETE'
});
if (response.success) {
this.showSuccess('Kontakt erfolgreich gelöscht');
await this.loadContacts();
this.currentView = 'list';
this.renderContacts();
}
} catch (error) {
console.error('[Contacts] Error deleting contact:', error);
this.showError('Fehler beim Löschen des Kontakts');
}
}
showInteractionModal() {
const modal = document.createElement('div');
modal.className = 'modal modal-medium';
modal.innerHTML = `
<div class="modal-header">
<h2>Neue Aktivität hinzufügen</h2>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<form id="interaction-form" class="modal-body">
<div class="form-group">
<label class="form-label">Typ</label>
<select name="type" class="form-control" required>
<option value="call">Anruf</option>
<option value="email">E-Mail</option>
<option value="meeting">Treffen</option>
<option value="note">Notiz</option>
<option value="task">Aufgabe</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Betreff</label>
<input type="text" name="subject" class="form-control" placeholder="Kurze Zusammenfassung">
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea name="content" class="form-control" rows="4" placeholder="Details..."></textarea>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" form="interaction-form" class="btn btn-primary">Hinzufügen</button>
</div>
`;
document.body.appendChild(modal);
modal.classList.add('show');
// Handle form submit
const form = modal.querySelector('#interaction-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = {
type: formData.get('type'),
subject: formData.get('subject'),
content: formData.get('content')
};
try {
const response = await api.request(
`/contacts/${this.currentContact.id}/interactions`,
{
method: 'POST',
body: data
}
);
if (response.success) {
this.showSuccess('Aktivität hinzugefügt');
// Refresh contact detail
const contactResponse = await api.request(`/contacts/${this.currentContact.id}`);
if (contactResponse.success) {
this.showContactDetail(contactResponse.data);
}
// Close modal
modal.remove();
}
} catch (error) {
console.error('[Contacts] Error adding interaction:', error);
this.showError('Fehler beim Hinzufügen der Aktivität');
}
});
// Close modal events
modal.querySelectorAll('[data-close-modal]').forEach(btn => {
btn.addEventListener('click', () => modal.remove());
});
}
handleAddContact() {
this.showContactForm();
}
handleFilterChange(e) {
this.activeFilter = e.target.value;
// Update active state for buttons
this.container.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active',
btn.dataset.filter === this.activeFilter
);
});
// Nur Anzeige aktualisieren, nicht neu laden
this.updateCounts();
this.renderContacts();
}
handleSearch(value) {
this.searchQuery = value;
this.loadContacts();
}
filterData() {
// Called by global search
this.handleSearch(this.searchQuery);
}
subscribeToUpdates() {
// Subscribe to relevant store updates
window.addEventListener('contact:created', () => this.loadContacts());
window.addEventListener('contact:updated', () => this.loadContacts());
window.addEventListener('contact:deleted', () => this.loadContacts());
// Subscribe to project changes to reload contacts
store.subscribe('projects', () => {
if (this.isInitialized) {
this.loadContacts();
}
});
}
showError(message) {
window.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type: 'error' }
}));
}
showSuccess(message) {
window.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type: 'success' }
}));
}
}
// Export singleton
export const contactsManager = new ContactsManager();
export function initContacts() {
return contactsManager.init();
}