00f07d76f6
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
453 lines
27 KiB
React
Executable File
453 lines
27 KiB
React
Executable File
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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|