Files
bellsystems-cp/frontend/src/crm/customers/CustomerForm.jsx

580 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}