update: Major Overhaul to all subsystems

This commit is contained in:
2026-03-07 11:32:18 +02:00
parent 810e81b323
commit c62188fda6
107 changed files with 20414 additions and 929 deletions

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
function primaryContact(customer, type) {
const contacts = customer.contacts || [];
const primary = contacts.find((c) => c.type === type && c.primary);
return primary?.value || contacts.find((c) => c.type === type)?.value || null;
}
export default function CustomerList() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = useState("");
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const fetchCustomers = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (tagFilter) params.set("tag", tagFilter);
const qs = params.toString();
const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`);
setCustomers(data.customers);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCustomers();
}, [search, tagFilter]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Customers</h1>
{canEdit && (
<button
onClick={() => navigate("/crm/customers/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
New Customer
</button>
)}
</div>
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name, location, email, phone, tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
<input
type="text"
placeholder="Filter by tag..."
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="w-40 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
/>
</div>
{error && (
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : customers.length === 0 ? (
<div
className="rounded-lg p-8 text-center text-sm border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
No customers found.
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
</tr>
</thead>
<tbody>
{customers.map((c, index) => {
const loc = c.location || {};
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
return (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "email") || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "phone") || "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(c.tags || []).slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
{tag}
</span>
))}
{(c.tags || []).length > 3 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
+{c.tags.length - 3}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}