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,579 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
const CONTACT_TYPES = ["email", "phone", "whatsapp", "other"];
const LANGUAGES = [
{ value: "el", label: "Greek" },
{ value: "en", label: "English" },
{ value: "de", label: "German" },
{ value: "fr", label: "French" },
{ value: "it", label: "Italian" },
];
const TITLES = ["", "Fr.", "Rev.", "Archim.", "Bp.", "Abp.", "Met.", "Mr.", "Mrs.", "Ms.", "Dr.", "Prof."];
const PRESET_TAGS = ["church", "monastery", "municipality", "school", "repeat-customer", "vip", "pending", "inactive"];
const CONTACT_TYPE_ICONS = {
email: "📧",
phone: "📞",
whatsapp: "💬",
other: "🔗",
};
const inputClass = "w-full px-3 py-2 text-sm rounded-md border";
const inputStyle = {
backgroundColor: "var(--bg-input)",
borderColor: "var(--border-primary)",
color: "var(--text-primary)",
};
const labelStyle = {
display: "block",
marginBottom: 4,
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
};
function Field({ label, children, style }) {
return (
<div style={style}>
<label style={labelStyle}>{label}</label>
{children}
</div>
);
}
function SectionCard({ title, children }) {
return (
<div
className="rounded-lg border p-5 mb-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-4" style={{ color: "var(--text-heading)" }}>{title}</h2>
{children}
</div>
);
}
const emptyContact = () => ({ type: "email", label: "", value: "", primary: false });
export default function CustomerForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const { user, hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const [form, setForm] = useState({
title: "",
name: "",
surname: "",
organization: "",
language: "el",
tags: [],
folder_id: "",
location: { city: "", country: "", region: "" },
contacts: [],
notes: [],
});
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [tagInput, setTagInput] = useState("");
const [newNoteText, setNewNoteText] = useState("");
const [editingNoteIdx, setEditingNoteIdx] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
useEffect(() => {
if (!isEdit) return;
api.get(`/crm/customers/${id}`)
.then((data) => {
setForm({
title: data.title || "",
name: data.name || "",
surname: data.surname || "",
organization: data.organization || "",
language: data.language || "el",
tags: data.tags || [],
folder_id: data.folder_id || "",
location: {
city: data.location?.city || "",
country: data.location?.country || "",
region: data.location?.region || "",
},
contacts: data.contacts || [],
notes: data.notes || [],
});
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id, isEdit]);
const set = (field, value) => setForm((f) => ({ ...f, [field]: value }));
const setLoc = (field, value) => setForm((f) => ({ ...f, location: { ...f.location, [field]: value } }));
// Tags
const addTag = (raw) => {
const tag = raw.trim();
if (tag && !form.tags.includes(tag)) {
set("tags", [...form.tags, tag]);
}
setTagInput("");
};
const removeTag = (tag) => set("tags", form.tags.filter((t) => t !== tag));
// Contacts
const addContact = () => set("contacts", [...form.contacts, emptyContact()]);
const removeContact = (i) => set("contacts", form.contacts.filter((_, idx) => idx !== i));
const setContact = (i, field, value) => {
const updated = form.contacts.map((c, idx) => idx === i ? { ...c, [field]: value } : c);
set("contacts", updated);
};
const setPrimaryContact = (i) => {
const type = form.contacts[i].type;
const updated = form.contacts.map((c, idx) => ({
...c,
primary: c.type === type ? idx === i : c.primary,
}));
set("contacts", updated);
};
// Notes
const addNote = () => {
if (!newNoteText.trim()) return;
const note = {
text: newNoteText.trim(),
by: user?.name || "unknown",
at: new Date().toISOString(),
};
set("notes", [...form.notes, note]);
setNewNoteText("");
};
const removeNote = (i) => {
set("notes", form.notes.filter((_, idx) => idx !== i));
if (editingNoteIdx === i) setEditingNoteIdx(null);
};
const startEditNote = (i) => {
setEditingNoteIdx(i);
setEditingNoteText(form.notes[i].text);
};
const saveEditNote = (i) => {
if (!editingNoteText.trim()) return;
const updated = form.notes.map((n, idx) =>
idx === i ? { ...n, text: editingNoteText.trim(), at: new Date().toISOString() } : n
);
set("notes", updated);
setEditingNoteIdx(null);
};
const buildPayload = () => ({
title: form.title.trim() || null,
name: form.name.trim(),
surname: form.surname.trim() || null,
organization: form.organization.trim() || null,
language: form.language,
tags: form.tags,
...(!isEdit && { folder_id: form.folder_id.trim().toLowerCase() }),
location: {
city: form.location.city.trim(),
country: form.location.country.trim(),
region: form.location.region.trim(),
},
contacts: form.contacts.filter((c) => c.value.trim()),
notes: form.notes,
});
const handleSave = async () => {
if (!form.name.trim()) { setError("Customer name is required."); return; }
if (!isEdit && !form.folder_id.trim()) { setError("Internal Folder ID is required."); return; }
if (!isEdit && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(form.folder_id.trim().toLowerCase())) {
setError("Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
return;
}
setSaving(true);
setError("");
try {
if (isEdit) {
await api.put(`/crm/customers/${id}`, buildPayload());
navigate(`/crm/customers/${id}`);
} else {
const res = await api.post("/crm/customers", buildPayload());
navigate(`/crm/customers/${res.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await api.delete(`/crm/customers/${id}`);
navigate("/crm/customers");
} catch (err) {
setError(err.message);
setSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
return (
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Customer" : "New Customer"}
</h1>
<div className="flex gap-2">
<button
onClick={() => navigate(isEdit ? `/crm/customers/${id}` : "/crm/customers")}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
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)", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
)}
</div>
</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>
)}
{/* Basic Info */}
<SectionCard title="Basic Info">
{/* Row 1: Title, Name, Surname */}
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Title">
<select className={inputClass} style={inputStyle} value={form.title}
onChange={(e) => set("title", e.target.value)}>
{TITLES.map((t) => <option key={t} value={t}>{t || "—"}</option>)}
</select>
</Field>
<Field label="Name *">
<input className={inputClass} style={inputStyle} value={form.name}
onChange={(e) => set("name", e.target.value)} placeholder="First name" />
</Field>
<Field label="Surname">
<input className={inputClass} style={inputStyle} value={form.surname}
onChange={(e) => set("surname", e.target.value)} placeholder="Last name" />
</Field>
</div>
{/* Row 2: Organization, Language, Folder ID */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
<Field label="Organization">
<input className={inputClass} style={inputStyle} value={form.organization}
onChange={(e) => set("organization", e.target.value)} placeholder="Church, organization, etc." />
</Field>
<Field label="Language">
<select className={inputClass} style={inputStyle} value={form.language}
onChange={(e) => set("language", e.target.value)}>
{LANGUAGES.map((l) => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</Field>
{!isEdit ? (
<Field label="Folder ID *">
<input
className={inputClass}
style={inputStyle}
value={form.folder_id}
onChange={(e) => set("folder_id", e.target.value.toLowerCase().replace(/[^a-z0-9\-]/g, ""))}
placeholder="e.g. saint-john-corfu"
/>
</Field>
) : (
<div>
<div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Folder ID</div>
<div style={{ fontSize: 13, color: "var(--text-primary)", padding: "6px 0" }}>{form.folder_id || "—"}</div>
</div>
)}
</div>
{!isEdit && (
<p className="text-xs" style={{ color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
Lowercase letters, numbers and hyphens only. This becomes the Nextcloud folder name and cannot be changed later.
</p>
)}
{/* Row 3: Tags */}
<div>
<label style={labelStyle}>Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
onClick={() => removeTag(tag)}
title="Click to remove"
>
{tag} ×
</span>
))}
</div>
{/* Preset quick-add tags */}
<div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_TAGS.filter((t) => !form.tags.includes(t)).map((t) => (
<button
key={t}
type="button"
onClick={() => addTag(t)}
className="px-2 py-0.5 text-xs rounded-full border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
+ {t}
</button>
))}
</div>
<input
className={inputClass}
style={inputStyle}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput);
}
}}
onBlur={() => tagInput.trim() && addTag(tagInput)}
placeholder="Type a custom tag and press Enter or comma..."
/>
</div>
</SectionCard>
{/* Location */}
<SectionCard title="Location">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
<Field label="City">
<input className={inputClass} style={inputStyle} value={form.location.city}
onChange={(e) => setLoc("city", e.target.value)} placeholder="City" />
</Field>
<Field label="Country">
<input className={inputClass} style={inputStyle} value={form.location.country}
onChange={(e) => setLoc("country", e.target.value)} placeholder="Country" />
</Field>
<Field label="Region">
<input className={inputClass} style={inputStyle} value={form.location.region}
onChange={(e) => setLoc("region", e.target.value)} placeholder="Region" />
</Field>
</div>
</SectionCard>
{/* Contacts */}
<SectionCard title="Contacts">
{form.contacts.map((c, i) => (
<div
key={i}
className="flex gap-2 mb-2 items-center"
>
<span className="text-base w-6 text-center flex-shrink-0">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
<select
className="px-2 py-2 text-sm rounded-md border w-32 flex-shrink-0"
style={inputStyle}
value={c.type}
onChange={(e) => setContact(i, "type", e.target.value)}
>
{CONTACT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
className="px-2 py-2 text-sm rounded-md border w-28 flex-shrink-0"
style={inputStyle}
value={c.label}
onChange={(e) => setContact(i, "label", e.target.value)}
placeholder="label (e.g. work)"
/>
<input
className={inputClass + " flex-1"}
style={inputStyle}
value={c.value}
onChange={(e) => setContact(i, "value", e.target.value)}
placeholder="value"
/>
<label className="flex items-center gap-1 text-xs flex-shrink-0 cursor-pointer" style={{ color: "var(--text-muted)" }}>
<input
type="radio"
name={`primary-${c.type}`}
checked={!!c.primary}
onChange={() => setPrimaryContact(i)}
className="cursor-pointer"
/>
Primary
</label>
<button
type="button"
onClick={() => removeContact(i)}
className="text-xs cursor-pointer hover:opacity-70 flex-shrink-0"
style={{ color: "var(--danger)" }}
>
×
</button>
</div>
))}
<button
type="button"
onClick={addContact}
className="mt-2 px-3 py-1.5 text-xs rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
+ Add Contact
</button>
</SectionCard>
{/* Notes */}
<SectionCard title="Notes">
{form.notes.length > 0 && (
<div className="mb-4 space-y-2">
{form.notes.map((note, i) => (
<div
key={i}
className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{editingNoteIdx === i ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<textarea
className={inputClass}
style={{ ...inputStyle, resize: "vertical", minHeight: 56 }}
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveEditNote(i);
if (e.key === "Escape") setEditingNoteIdx(null);
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => saveEditNote(i)}
disabled={!editingNoteText.trim()}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: editingNoteText.trim() ? 1 : 0.5 }}
>Save</button>
<button
type="button"
onClick={() => setEditingNoteIdx(null)}
className="px-2 py-1 text-xs rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}
>Cancel</button>
</div>
</div>
) : (
<>
<p>{note.text}</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => startEditNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>Edit</button>
<button
type="button"
onClick={() => removeNote(i)}
className="text-xs cursor-pointer hover:opacity-70"
style={{ color: "var(--danger)", background: "none", border: "none", padding: 0 }}
>Remove</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
<div className="flex gap-2">
<textarea
className={inputClass + " flex-1"}
style={{ ...inputStyle, resize: "vertical", minHeight: 64 }}
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
placeholder="Add a note..."
/>
<button
type="button"
onClick={addNote}
disabled={!newNoteText.trim()}
className="px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 self-start"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: newNoteText.trim() ? 1 : 0.5 }}
>
Add
</button>
</div>
</SectionCard>
{/* Delete */}
{isEdit && canEdit && (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-sm font-semibold mb-2" style={{ color: "var(--danger)" }}>Danger Zone</h2>
{!showDeleteConfirm ? (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--danger)", color: "var(--danger)" }}
>
Delete Customer
</button>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>
Are you sure? This cannot be undone.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}