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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 31–33" /></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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user