Doku & Aufräumen: CLAUDE.md/ARCHITECTURE.md, Tag-Schema, Legacy-Views weg

CLAUDE.md (Kurzform: was zu tun/lassen ist) und ARCHITECTURE.md
(vollständige Repo-Karte mit Verzeichnis, Datenfluss, View-Inventar,
Updater-Pipeline, Schwachstellen) als neue Onboarding-Dokumente.

Tag-Schema in Doku und Skript-Kommentar an die tatsächliche Konvention
angeglichen: Gitea-Tag ohne v-Prefix (latest.json-URL nutzt
/releases/download/<VERSION>/). Betrifft scripts/release.sh, README.md
und ARCHITECTURE.md §9+§10.

Legacy-Views Contacts.jsx und Clients.jsx entfernt — durch Persons.jsx
ersetzt, in NAV_ITEMS nicht mehr verlinkt, kein Import mehr im Code.
ARCHITECTURE.md §5/§12/§14 entsprechend aktualisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 03:27:39 +02:00
parent 0fc4dd0e08
commit c71feddf63
6 changed files with 496 additions and 911 deletions
-452
View File
@@ -1,452 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm } from "../components/UI.jsx";
export default
function Clients({ data, update, modal, setModal, setView }) {
const clients = data.clients || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToClient || null;
window.__navToClient = null;
return id;
});
const [search, setSearch] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const [contactModal, setContactModal] = useState(null);
const [contactForm, setContactForm] = useState({ name: "", position: "", email: "", phone: "" });
const [showHauptPicker, setShowHauptPicker] = useState(false);
const emptyForm = {
name: "", street: "", zip: "", city: "", country: "CH",
email: "", phone: "", website: "",
contacts: [],
_contactName: "", _contactPosition: "",
};
const [form, setForm] = useState(emptyForm);
const selectedClient = clients.find(c => c.id === selectedId) || null;
// ── Client speichern ──
const save = () => {
if (!form.name.trim()) return;
const { _contactName, _contactPosition, ...clientData } = form;
let contacts = clientData.contacts || [];
if (_contactName.trim() && !modal?.id) {
contacts = [{ id: generateId(), name: _contactName.trim(), position: _contactPosition.trim(), email: "", phone: "" }];
}
const client = { ...clientData, contacts, id: modal?.id || generateId() };
update("clients", modal?.id ? clients.map(c => c.id === modal.id ? client : c) : [...clients, client]);
setModal(null);
};
const openNew = () => { setForm(emptyForm); setModal({ type: "client" }); };
const openEdit = (c) => {
setForm({ ...emptyForm, ...c, _contactName: "", _contactPosition: "" });
setModal({ type: "client", id: c.id });
};
const del = async (id) => {
if (await askConfirm("Kunde löschen? Alle zugehörigen Projekte verlieren die Kundenzuordnung.")) {
update("clients", clients.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Kontakt speichern ──
const saveContact = () => {
if (!contactForm.name.trim()) return;
const client = clients.find(c => c.id === contactModal.clientId);
if (!client) return;
const contacts = client.contacts || [];
const updated = contactModal.contactId
? contacts.map(ct => ct.id === contactModal.contactId ? { ...ct, ...contactForm } : ct)
: [...contacts, { ...contactForm, id: generateId() }];
update("clients", clients.map(c => c.id === client.id ? { ...c, contacts: updated } : c));
setContactModal(null);
};
const delContact = async (clientId, contactId) => {
if (await askConfirm("Kontaktperson löschen?")) {
const client = clients.find(c => c.id === clientId);
update("clients", clients.map(c => c.id === clientId ? { ...c, contacts: (c.contacts || []).filter(ct => ct.id !== contactId) } : c));
}
};
// ── Detail-Ansicht ──
if (selectedId && selectedClient) {
const projs = (data.projects || []).filter(p => p.clientId === selectedId).sort((a, b) => (b.startDate || "").localeCompare(a.startDate || ""));
const invoices = (data.invoices || []).filter(i => i.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const quotes = (data.quotes || []).filter(q => q.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const contacts = selectedClient.contacts || [];
const hauptkontakt = contacts[0] || null;
const addressLine = [selectedClient.street, [selectedClient.zip, selectedClient.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
const navTo = (view) => { window.__navClientId = selectedId; setView(view); };
const formatCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kunden</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedClient.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedClient)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedClient.email, href: `mailto:${selectedClient.email}` },
{ label: "Telefon", value: selectedClient.phone },
{ label: "Website", value: selectedClient.website, href: selectedClient.website?.startsWith("http") ? selectedClient.website : selectedClient.website ? `https://${selectedClient.website}` : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{contacts.length > 0 && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2", position: "relative" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, color: "#888" }}>HAUPTKONTAKT</div>
{contacts.length > 1 && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: "2px 8px" }} onClick={() => setShowHauptPicker(v => !v)}>
ändern
</button>
)}
</div>
{showHauptPicker ? (
<div style={{ border: "1px solid #ece8e2", borderRadius: 6, overflow: "hidden" }}>
{contacts.map((ct, i) => (
<button key={ct.id} onClick={() => {
const reordered = [ct, ...contacts.filter(x => x.id !== ct.id)];
update("clients", clients.map(c => c.id === selectedId ? { ...c, contacts: reordered } : c));
setShowHauptPicker(false);
}} style={{
display: "block", width: "100%", textAlign: "left", padding: "9px 12px",
background: i === 0 ? "#f5f2ec" : "white", border: "none", borderBottom: i < contacts.length - 1 ? "1px solid #f0ede8" : "none",
cursor: "pointer", fontFamily: "inherit",
}}>
<div style={{ fontWeight: i === 0 ? 600 : 400, fontSize: 13 }}>{ct.name}</div>
{ct.position && <div style={{ fontSize: 11, color: "#888" }}>{ct.position}</div>}
</button>
))}
</div>
) : hauptkontakt ? (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptkontakt.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 6 }}>
{hauptkontakt.email && <a href={`mailto:${hauptkontakt.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptkontakt.email}</a>}
{hauptkontakt.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptkontakt.phone}</span>}
</div>
</>
) : null}
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: contacts.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({contacts.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setContactForm({ name: "", position: "", email: "", phone: "" }); setContactModal({ clientId: selectedId }); }}>+ Hinzufügen</button>
</div>
{contacts.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
) : (
contacts.map((ct, i) => (
<div key={ct.id} style={{ padding: "12px 20px", borderBottom: i < contacts.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{ct.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{ct.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{ct.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{ct.email && <a href={`mailto:${ct.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{ct.email}</a>}
{ct.phone && <span style={{ fontSize: 12, color: "#555" }}>{ct.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setContactForm({ name: ct.name, position: ct.position || "", email: ct.email || "", phone: ct.phone || "" }); setContactModal({ clientId: selectedId, contactId: ct.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delContact(selectedId, ct.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Projekte */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: projs.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>PROJEKTE ({projs.length})</span>
{projs.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("projects")}>Alle anzeigen </button>}
</div>
{projs.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Projekte.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Projekt</th><th>Kategorie</th><th>Status</th><th style={{ textAlign: "right" }}>Budget</th></tr></thead>
<tbody>
{projs.slice(0, 5).map(p => (
<tr key={p.id}>
<td><strong>{p.name}</strong>{p.number && <span style={{ fontSize: 11, color: "#aaa", marginLeft: 6 }}>{p.number}</span>}</td>
<td style={{ fontSize: 12, color: "#888" }}>{p.category || "—"}</td>
<td><span style={{ fontSize: 11, color: p.status === "aktiv" ? "#2d6a4f" : "#888" }}>{p.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12 }}>{p.budget > 0 ? formatCHF(p.budget) : "—"}</td>
</tr>
))}
</tbody>
</table>
{projs.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{projs.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("projects")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Rechnungen */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: invoices.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>RECHNUNGEN ({invoices.length})</span>
{invoices.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("invoices")}>Alle anzeigen </button>}
</div>
{invoices.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Rechnungen.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Projekt</th><th>Status</th><th style={{ textAlign: "right" }}>Betrag</th></tr></thead>
<tbody>
{invoices.slice(0, 5).map(inv => {
const proj = inv.projectId ? (data.projects || []).find(p => p.id === inv.projectId) : null;
return (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(inv.date)}</td>
<td style={{ fontSize: 12, color: "#555" }}>{proj?.name || "—"}</td>
<td><span style={{ fontSize: 11, color: inv.status === "bezahlt" ? "#2d6a4f" : inv.status === "überfällig" ? "#8a1a1a" : "#888" }}>{inv.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(inv.total)}</td>
</tr>
);
})}
</tbody>
</table>
{invoices.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{invoices.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("invoices")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Offerten */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: quotes.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>OFFERTEN ({quotes.length})</span>
{quotes.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("quotes")}>Alle anzeigen </button>}
</div>
{quotes.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Offerten.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Modus</th><th>Status</th><th style={{ textAlign: "right" }}>Honorar</th></tr></thead>
<tbody>
{quotes.slice(0, 5).map(q => (
<tr key={q.id}>
<td><strong>{q.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(q.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Frei"}</td>
<td><span style={{ fontSize: 11, color: q.status === "genehmigt" ? "#2d6a4f" : "#888" }}>{q.status || "—"}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(q.total)}</td>
</tr>
))}
</tbody>
</table>
{quotes.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{quotes.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("quotes")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Kontakt-Modal */}
{contactModal && (
<Modal title={contactModal.contactId ? "Kontakt bearbeiten" : "Neuer Ansprechpartner"} onClose={() => setContactModal(null)} onSave={saveContact}>
<div className="form-row">
<FormField label="Name *"><input value={contactForm.name} onChange={e => setContactForm({ ...contactForm, name: e.target.value })} autoFocus /></FormField>
<FormField label="Funktion / Position"><input value={contactForm.position} onChange={e => setContactForm({ ...contactForm, position: e.target.value })} placeholder="z.B. Geschäftsführer, Bauleiter…" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={contactForm.email} onChange={e => setContactForm({ ...contactForm, email: e.target.value })} /></FormField>
<FormField label="Telefon"><input value={contactForm.phone} onChange={e => setContactForm({ ...contactForm, phone: e.target.value })} /></FormField>
</div>
</Modal>
)}
{/* Client-Edit-Modal */}
{modal?.type === "client" && modal.id && (
<Modal title="Kunde bearbeiten" onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm)}
</Modal>
)}
</div>
);
}
// ── Listen-Ansicht ──
const filteredClients = clients.filter(c => {
if (!search) return true;
const q = search.toLowerCase();
return [c.name, c.city, c.email, c.street, ...(c.contacts || []).map(ct => ct.name)].some(v => v?.toLowerCase().includes(q));
});
const clientGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filteredClients }];
if (groupBy === "alpha") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "city") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.city || "Ohne Ort"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ClientTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firmenname</th>
<th>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ textAlign: "center", width: 80 }}>Kontakte</th>
<th style={{ textAlign: "center", width: 80 }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const projs = (data.projects || []).filter(p => p.clientId === c.id).length;
const cts = c.contacts || [];
const hauptkontakt = cts[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{hauptkontakt ? (
<>
<div style={{ fontWeight: 500 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888" }}>{hauptkontakt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{cts.length || "—"}</td>
<td style={{ textAlign: "center", color: projs ? "#2d6a4f" : "#ccc", fontSize: 12, fontWeight: projs ? 600 : 400 }}>{projs || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kunden" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kunde</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, alignItems: "center" }}>
<input placeholder="Suchen…" value={search} onChange={e => setSearch(e.target.value)}
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 170 }}>
<option value="alpha">Alphabetisch</option>
<option value="city">Nach Ort</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{clients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kunden erfasst.</div>
) : filteredClients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : clientGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ClientTable items={group.items} />
</div>
))}
{modal?.type === "client" && (
<Modal title={modal.id ? "Kunde bearbeiten" : "Neuer Kunde"} onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm, !modal.id)}
</Modal>
)}
</div>
);
}
function clientFormFields(form, setForm, isNew = false) {
return (
<>
<FormField label="Firmenname *">
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus placeholder="z.B. Müller Immobilien AG" />
</FormField>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={form.street || ""} onChange={e => setForm({ ...form, street: e.target.value })} placeholder="Bahnhofstrasse 1" /></FormField>
<FormField label="PLZ"><input value={form.zip || ""} onChange={e => setForm({ ...form, zip: e.target.value })} style={{ maxWidth: 100 }} /></FormField>
<FormField label="Ort"><input value={form.city || ""} onChange={e => setForm({ ...form, city: e.target.value })} /></FormField>
<FormField label="Land"><input value={form.country || "CH"} onChange={e => setForm({ ...form, country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 70 }} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={form.email || ""} onChange={e => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Telefon Firma"><input value={form.phone || ""} onChange={e => setForm({ ...form, phone: e.target.value })} /></FormField>
<FormField label="Website"><input value={form.website || ""} onChange={e => setForm({ ...form, website: e.target.value })} placeholder="www.beispiel.ch" /></FormField>
</div>
{isNew && (
<>
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Referenzperson">
<input value={form._contactName || ""} onChange={e => setForm({ ...form, _contactName: e.target.value })} placeholder="z.B. Hans Müller" />
</FormField>
<FormField label="Funktion / Position">
<input value={form._contactPosition || ""} onChange={e => setForm({ ...form, _contactPosition: e.target.value })} placeholder="z.B. Geschäftsführer" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Ansprechpartner können in der Kundendetailseite hinzugefügt werden.</div>
</>
)}
</>
);
}
-456
View File
@@ -1,456 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm , DateInput } from "../components/UI.jsx";
const CONTACT_TYPES = [
"Elektroplaner", "HLKSE-Planer", "Statiker", "Tragwerksplaner",
"Kostenplaner", "Landschaftsarchitekt", "Bauphysiker",
"Vermessungsingenieur", "Brandschutzspezialist", "Geologe",
"Generalunternehmer", "Fachplaner", "Sonstiges",
];
const fmtCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
export default
function Contacts({ data, update }) {
const contacts = data.contacts || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToContact || null;
window.__navToContact = null;
return id;
});
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const emptyFirm = {
name: "", type: "", street: "", zip: "", city: "", email: "", phone: "", website: "", note: "",
contacts: [], honorarOffers: [],
_personName: "", _personPosition: "",
};
const [firmModal, setFirmModal] = useState(null);
const [firmForm, setFirmForm] = useState(emptyFirm);
const [personModal, setPersonModal] = useState(null);
const [personForm, setPersonForm] = useState({ name: "", position: "", email: "", phone: "" });
const [honorarModal, setHonorarModal] = useState(null);
const [honorarForm, setHonorarForm] = useState({ date: "", amount: "", phase: "", description: "", note: "" });
const selectedContact = contacts.find(c => c.id === selectedId) || null;
// ── Firm CRUD ──
const saveFirm = () => {
if (!firmForm.name.trim()) return;
const { _personName, _personPosition, ...firmData } = firmForm;
let persons = firmData.contacts || [];
if (_personName.trim() && !firmModal?.id) {
persons = [{ id: generateId(), name: _personName.trim(), position: _personPosition.trim(), email: "", phone: "" }];
}
const firm = { ...firmData, contacts: persons, id: firmModal?.id || generateId() };
update("contacts", firmModal?.id ? contacts.map(c => c.id === firmModal.id ? firm : c) : [...contacts, firm]);
setFirmModal(null);
};
const openNew = () => { setFirmForm(emptyFirm); setFirmModal({}); };
const openEdit = (c) => { setFirmForm({ ...emptyFirm, ...c, _personName: "", _personPosition: "" }); setFirmModal({ id: c.id }); };
const delFirm = async (id) => {
if (await askConfirm("Kontakt löschen?")) {
update("contacts", contacts.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Person CRUD ──
const savePerson = () => {
if (!personForm.name.trim()) return;
const firm = contacts.find(c => c.id === personModal.contactId);
if (!firm) return;
const persons = firm.contacts || [];
const updated = personModal.personId
? persons.map(p => p.id === personModal.personId ? { ...p, ...personForm } : p)
: [...persons, { ...personForm, id: generateId() }];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, contacts: updated } : c));
setPersonModal(null);
};
const delPerson = async (contactId, personId) => {
if (await askConfirm("Person löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, contacts: (c.contacts || []).filter(p => p.id !== personId) } : c));
}
};
// ── Honorar CRUD ──
const saveHonorar = () => {
const firm = contacts.find(c => c.id === honorarModal.contactId);
if (!firm) return;
const offers = firm.honorarOffers || [];
const offer = { id: honorarModal.offerId || generateId(), date: honorarForm.date, amount: parseFloat(honorarForm.amount) || 0, phase: honorarForm.phase, description: honorarForm.description, note: honorarForm.note };
const updated = honorarModal.offerId ? offers.map(o => o.id === honorarModal.offerId ? offer : o) : [...offers, offer];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, honorarOffers: updated } : c));
setHonorarModal(null);
};
const delHonorar = async (contactId, offerId) => {
if (await askConfirm("Honorarangebot löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, honorarOffers: (c.honorarOffers || []).filter(o => o.id !== offerId) } : c));
}
};
// ── Form fields (shared new/edit) ──
const firmFormFields = (isNew) => (
<>
<div className="form-row">
<FormField label="Firmenname *">
<input value={firmForm.name} onChange={e => setFirmForm(f => ({ ...f, name: e.target.value }))} autoFocus placeholder="z.B. Elektroplaner AG" />
</FormField>
<FormField label="Typ">
<select value={firmForm.type} onChange={e => setFirmForm(f => ({ ...f, type: e.target.value }))}>
<option value=""> wählen </option>
{CONTACT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={firmForm.street || ""} onChange={e => setFirmForm(f => ({ ...f, street: e.target.value }))} /></FormField>
<FormField label="PLZ"><input value={firmForm.zip || ""} onChange={e => setFirmForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 90 }} /></FormField>
<FormField label="Ort"><input value={firmForm.city || ""} onChange={e => setFirmForm(f => ({ ...f, city: e.target.value }))} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={firmForm.email || ""} onChange={e => setFirmForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon Firma"><input value={firmForm.phone || ""} onChange={e => setFirmForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
<FormField label="Website"><input value={firmForm.website || ""} onChange={e => setFirmForm(f => ({ ...f, website: e.target.value }))} placeholder="www.beispiel.ch" /></FormField>
</div>
<FormField label="Bemerkung"><input value={firmForm.note || ""} onChange={e => setFirmForm(f => ({ ...f, note: e.target.value }))} /></FormField>
{isNew && (
<>
<div className="section-divider" style={{ marginTop: 16, marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Ansprechpartner">
<input value={firmForm._personName || ""} onChange={e => setFirmForm(f => ({ ...f, _personName: e.target.value }))} placeholder="z.B. Max Muster" />
</FormField>
<FormField label="Funktion / Position">
<input value={firmForm._personPosition || ""} onChange={e => setFirmForm(f => ({ ...f, _personPosition: e.target.value }))} placeholder="z.B. Projektleiter" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Personen können in der Detailansicht hinzugefügt werden.</div>
</>
)}
</>
);
// ── Detail view ──
if (selectedId && selectedContact) {
const persons = selectedContact.contacts || [];
const offers = selectedContact.honorarOffers || [];
const hauptperson = persons[0] || null;
const linkedProjects = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === selectedId));
const addressLine = [selectedContact.street, [selectedContact.zip, selectedContact.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kontakte</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
{selectedContact.type && <div style={{ fontSize: 11, color: "#888", marginBottom: 4, letterSpacing: "0.08em" }}>{selectedContact.type.toUpperCase()}</div>}
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedContact.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedContact)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => delFirm(selectedContact.id)}>Löschen</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedContact.email, href: selectedContact.email ? `mailto:${selectedContact.email}` : null },
{ label: "Telefon", value: selectedContact.phone },
{ label: "Website", value: selectedContact.website, href: selectedContact.website ? (selectedContact.website.startsWith("http") ? selectedContact.website : `https://${selectedContact.website}`) : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{selectedContact.note && <div style={{ marginTop: 12, fontSize: 12, color: "#555", lineHeight: 1.5 }}>{selectedContact.note}</div>}
{persons.length > 0 && hauptperson && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>HAUPTKONTAKT</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptperson.name}</div>
{hauptperson.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptperson.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{hauptperson.email && <a href={`mailto:${hauptperson.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptperson.email}</a>}
{hauptperson.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptperson.phone}</span>}
</div>
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: persons.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({persons.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setPersonForm({ name: "", position: "", email: "", phone: "" }); setPersonModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{persons.length === 0
? <div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
: persons.map((p, i) => (
<div key={p.id} style={{ padding: "12px 20px", borderBottom: i < persons.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{p.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{p.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{p.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{p.email && <a href={`mailto:${p.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{p.email}</a>}
{p.phone && <span style={{ fontSize: 12, color: "#555" }}>{p.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setPersonForm({ name: p.name, position: p.position || "", email: p.email || "", phone: p.phone || "" }); setPersonModal({ contactId: selectedId, personId: p.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delPerson(selectedId, p.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
}
</div>
</div>
{/* Honorar-Angebote */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: offers.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>HONORAR-ANGEBOTE ({offers.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setHonorarForm({ date: new Date().toISOString().slice(0, 10), amount: "", phase: "", description: "", note: "" }); setHonorarModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{offers.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Honorar-Angebote erfasst.</div>
: (
<table>
<thead><tr><th style={{ width: 110 }}>Datum</th><th>Beschrieb</th><th style={{ width: 120 }}>Phase</th><th style={{ width: 140, textAlign: "right" }}>Betrag</th><th style={{ width: 70 }}></th></tr></thead>
<tbody>
{[...offers].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(o => (
<tr key={o.id}>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(o.date)}</td>
<td>
<div style={{ fontSize: 13 }}>{o.description || <span style={{ color: "#aaa" }}></span>}</div>
{o.note && <div style={{ fontSize: 11, color: "#888" }}>{o.note}</div>}
</td>
<td style={{ fontSize: 12, color: "#888" }}>{o.phase || "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtCHF(o.amount)}</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setHonorarForm({ date: o.date || "", amount: o.amount?.toString() || "", phase: o.phase || "", description: o.description || "", note: o.note || "" }); setHonorarModal({ contactId: selectedId, offerId: o.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delHonorar(selectedId, o.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
{offers.length > 1 && (
<tfoot>
<tr>
<td colSpan={3} style={{ textAlign: "right", fontSize: 11, color: "#888", paddingRight: 8 }}>Total</td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{fmtCHF(offers.reduce((s, o) => s + (parseFloat(o.amount) || 0), 0))}</td>
<td />
</tr>
</tfoot>
)}
</table>
)
}
</div>
{/* Beteiligt an */}
{linkedProjects.length > 0 && (
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>BETEILIGT AN ({linkedProjects.length})</div>
<table>
<thead><tr><th>Projekt</th><th style={{ width: 160 }}>Kunde</th><th style={{ width: 110 }}>Status</th></tr></thead>
<tbody>
{linkedProjects.map(proj => {
const client = (data.clients || []).find(c => c.id === proj.clientId);
return (
<tr key={proj.id}>
<td><strong>{proj.number ? <span style={{ color: "#b07848", marginRight: 8 }}>{proj.number}</span> : null}{proj.name}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{client?.name || "—"}</td>
<td><span style={{ fontSize: 11, padding: "2px 8px", borderRadius: 3, background: "#f5f2ec", color: "#555" }}>{proj.status}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Person modal */}
{personModal && (
<Modal title={personModal.personId ? "Person bearbeiten" : "Person hinzufügen"} onClose={() => setPersonModal(null)} onSave={savePerson}>
<div className="form-row">
<FormField label="Name *"><input value={personForm.name} onChange={e => setPersonForm(f => ({ ...f, name: e.target.value }))} autoFocus /></FormField>
<FormField label="Funktion / Rolle"><input value={personForm.position} onChange={e => setPersonForm(f => ({ ...f, position: e.target.value }))} placeholder="z.B. Projektleiter" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={personForm.email} onChange={e => setPersonForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon"><input value={personForm.phone} onChange={e => setPersonForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
</div>
</Modal>
)}
{/* Honorar modal */}
{honorarModal && (
<Modal title={honorarModal.offerId ? "Angebot bearbeiten" : "Honorar-Angebot erfassen"} onClose={() => setHonorarModal(null)} onSave={saveHonorar}>
<div className="form-row">
<FormField label="Datum"><DateInput value={honorarForm.date} onChange={e => setHonorarForm(f => ({ ...f, date: e.target.value }))} /></FormField>
<FormField label="Betrag (CHF)"><input type="number" min="0" step="100" value={honorarForm.amount} onChange={e => setHonorarForm(f => ({ ...f, amount: e.target.value }))} placeholder="0" /></FormField>
</div>
<FormField label="Beschrieb"><input value={honorarForm.description} onChange={e => setHonorarForm(f => ({ ...f, description: e.target.value }))} placeholder="z.B. Elektroplanung Rohbau" /></FormField>
<FormField label="Phase"><input value={honorarForm.phase} onChange={e => setHonorarForm(f => ({ ...f, phase: e.target.value }))} placeholder="z.B. Phase 3133" /></FormField>
<FormField label="Bemerkung"><input value={honorarForm.note} onChange={e => setHonorarForm(f => ({ ...f, note: e.target.value }))} /></FormField>
</Modal>
)}
{/* Edit modal */}
{firmModal && (
<Modal title="Kontakt bearbeiten" onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(false)}
</Modal>
)}
</div>
);
}
// ── List view ──
const allTypes = [...new Set(contacts.map(c => c.type).filter(Boolean))].sort();
const filtered = contacts
.filter(c =>
(!typeFilter || c.type === typeFilter) &&
(!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
(c.type || "").toLowerCase().includes(search.toLowerCase()) ||
(c.contacts || []).some(p => p.name.toLowerCase().includes(search.toLowerCase())))
)
.sort((a, b) => a.name.localeCompare(b.name, "de"));
const contactGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filtered }];
if (groupBy === "alpha") {
const g = {};
filtered.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "type") {
const g = {};
filtered.forEach(c => { const k = c.type || "Ohne Typ"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ContactTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firma</th>
<th style={{ width: 140 }}>Typ</th>
<th style={{ width: 160 }}>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ width: 80, textAlign: "center" }}>Personen</th>
<th style={{ width: 80, textAlign: "center" }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const persons = c.contacts || [];
const haupt = persons[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
const projCount = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === c.id)).length;
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>{c.type || <span style={{ color: "#ccc" }}></span>}</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{haupt ? (
<>
<div style={{ fontWeight: 500 }}>{haupt.name}</div>
{haupt.position && <div style={{ fontSize: 11, color: "#888" }}>{haupt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{persons.length || "—"}</td>
<td style={{ textAlign: "center", fontSize: 12, color: projCount > 0 ? "#1a4e8a" : "#ccc", fontWeight: projCount > 0 ? 600 : 400 }}>{projCount || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delFirm(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kontakte" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kontakt</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen…"
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
{allTypes.length > 0 && (
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{ fontSize: 12, minWidth: 160 }}>
<option value="">Alle Typen</option>
{allTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
)}
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 160 }}>
<option value="alpha">Alphabetisch</option>
<option value="type">Nach Typ</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{contacts.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kontakte erfasst.</div>
) : filtered.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : contactGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ContactTable items={group.items} />
</div>
))}
{firmModal && (
<Modal title={firmModal.id ? "Kontakt bearbeiten" : "Neuer Kontakt"} onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(!firmModal.id)}
</Modal>
)}
</div>
);
}