580 lines
21 KiB
JavaScript
580 lines
21 KiB
JavaScript
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>
|
||
);
|
||
}
|