feat(contacts): CRUD de contactos (vault .md fuente de verdad + reflejo vCard)
Añade alta, edición y borrado de contactos (personas y organizaciones) a la
app osint_web. La fuente de verdad es la ficha .md del vault Obsidian
(CONVENTIONS.md §3b/§6); Xandikos es el retransmisor al móvil.
Backend (server/main.py):
- POST /api/contact: genera slug, escribe la ficha .md con el frontmatter
canónico + PUT del vCard a Xandikos. 409 si el slug ya existe.
- PUT /api/contact/{slug}: merge del frontmatter (preserva campos heredados)
+ re-PUT del vCard. 404 si no existe.
- DELETE /api/contact/{slug}: borra la ficha .md + DELETE del vCard. 404 si
no existe.
Cada escritura invalida la caché DAV para que el cambio se vea ya en la app.
Registry-first: orquesta create/update/delete_obsidian_note del grupo obsidian
y carddav_put_vcard/dav_delete_resource del grupo dav (sin reimplementar
parseo ni HTTP). Mapea los campos OSINT a propiedades X-OSINT-* del vCard.
Frontend (ContactsView.tsx + api.ts + format.ts):
- Botón "Nuevo contacto" → modal con formulario Mantine (TextInput,
TagsInput aliases, Select contexto, Textarea notas).
- Detalle: botones "Editar" (formulario precargado) y "Borrar" (con
confirmación). Tras guardar refresca la lista.
- Helper slugify (replica slugify_obsidian_name) para resolver la ficha.
Tests: 6 nuevos casos (ciclo crear→editar→borrar con .md real + reflejo vCard
mockeado, organización, 404s, tipo inválido, preserva campos heredados). Suite
27 passed. Ciclo e2e real verificado contra Xandikos + vault (vCard creado,
editado y borrado; slug zz-test-crud limpiado). pnpm build verde (React 19 +
Mantine v9).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,69 @@ export const fetchSearch = (q: string) =>
|
||||
|
||||
export const fetchContacts = () => getJSON<ContactsPayload>("/contacts");
|
||||
|
||||
// --- CRUD de contactos: ficha .md del vault (verdad) + reflejo del vCard ----
|
||||
|
||||
export interface ContactInput {
|
||||
tipo: "persona" | "organizacion";
|
||||
nombre: string;
|
||||
aliases: string[];
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
dni: string | null;
|
||||
direccion: string | null;
|
||||
pais: string | null;
|
||||
contexto: string | null;
|
||||
relaciones: string[];
|
||||
notas: string | null;
|
||||
}
|
||||
|
||||
export interface ContactWriteResult {
|
||||
status: string;
|
||||
slug: string;
|
||||
uid: string;
|
||||
deleted?: boolean;
|
||||
dav?: { status?: string; http_status?: number; error?: string };
|
||||
}
|
||||
|
||||
async function sendJSON<T>(
|
||||
path: string,
|
||||
method: "POST" | "PUT" | "DELETE",
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const j = await res.json();
|
||||
detail = j?.detail || j?.error || "";
|
||||
} catch {
|
||||
/* respuesta no JSON */
|
||||
}
|
||||
throw new Error(`HTTP ${res.status}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const createContact = (data: ContactInput) =>
|
||||
sendJSON<ContactWriteResult>("/contact", "POST", data);
|
||||
|
||||
export const updateContact = (slug: string, data: ContactInput) =>
|
||||
sendJSON<ContactWriteResult>(
|
||||
`/contact/${encodeURIComponent(slug)}`,
|
||||
"PUT",
|
||||
data,
|
||||
);
|
||||
|
||||
export const deleteContact = (slug: string) =>
|
||||
sendJSON<ContactWriteResult>(
|
||||
`/contact/${encodeURIComponent(slug)}`,
|
||||
"DELETE",
|
||||
);
|
||||
|
||||
export const fetchCalendar = (from = "", to = "") => {
|
||||
const qs = new URLSearchParams();
|
||||
if (from) qs.set("from", from);
|
||||
|
||||
@@ -19,6 +19,24 @@ const MESES = [
|
||||
"dic",
|
||||
];
|
||||
|
||||
/**
|
||||
* Slug kebab-case estable a partir de un nombre, replicando el
|
||||
* `slugify_obsidian_name` del registry (transliteración Unicode + minúsculas +
|
||||
* colapsar no-[a-z0-9] a un guion). Se usa para resolver la ficha .md de un
|
||||
* contacto al editar/borrar (el archivo se llama `<slug>.md`). El backend valida
|
||||
* la existencia, esto solo predice el nombre del recurso.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "") // quita marcas diacríticas combinantes
|
||||
.replace(/ñ/g, "n")
|
||||
.replace(/Ñ/g, "n")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** "2026-06-07" → "07/06/2026". Devuelve el original si no matchea. */
|
||||
export function formatISODate(value: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
|
||||
+428
-108
@@ -3,31 +3,113 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
TagsInput,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconAt,
|
||||
IconEdit,
|
||||
IconNote,
|
||||
IconPhone,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { fetchContacts, type Contact } from "../api";
|
||||
import {
|
||||
createContact,
|
||||
deleteContact,
|
||||
fetchContacts,
|
||||
updateContact,
|
||||
type Contact,
|
||||
type ContactInput,
|
||||
} from "../api";
|
||||
import { slugify } from "../format";
|
||||
|
||||
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
||||
// buscador por nombre / alias / teléfono / email) y la ficha del contacto
|
||||
// seleccionado a la derecha (todos los campos, incluido el bloque osint y nota).
|
||||
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
|
||||
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"),
|
||||
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos).
|
||||
|
||||
type FormTipo = "persona" | "organizacion";
|
||||
|
||||
interface FormState {
|
||||
tipo: FormTipo;
|
||||
nombre: string;
|
||||
aliases: string[];
|
||||
telefono: string;
|
||||
email: string;
|
||||
dni: string;
|
||||
direccion: string;
|
||||
pais: string;
|
||||
contexto: string;
|
||||
notas: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
tipo: "persona",
|
||||
nombre: "",
|
||||
aliases: [],
|
||||
telefono: "",
|
||||
email: "",
|
||||
dni: "",
|
||||
direccion: "",
|
||||
pais: "",
|
||||
contexto: "",
|
||||
notas: "",
|
||||
};
|
||||
|
||||
// Construye el estado del formulario a partir de un contacto existente (para
|
||||
// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el
|
||||
// backend expone tras parsear el vCard.
|
||||
function formFromContact(c: Contact): FormState {
|
||||
const osint = c.osint ?? {};
|
||||
return {
|
||||
tipo: "persona",
|
||||
nombre: c.nombre ?? "",
|
||||
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||
telefono: c.telefonos?.[0] ?? "",
|
||||
email: c.correos?.[0] ?? "",
|
||||
dni: osint.dni ?? "",
|
||||
direccion: osint.direccion ?? "",
|
||||
pais: osint.pais ?? "",
|
||||
contexto: osint.contexto ?? "",
|
||||
notas: c.nota ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formToInput(f: FormState): ContactInput {
|
||||
const t = (v: string) => (v.trim() ? v.trim() : null);
|
||||
return {
|
||||
tipo: f.tipo,
|
||||
nombre: f.nombre.trim(),
|
||||
aliases: f.aliases.map((s) => s.trim()).filter(Boolean),
|
||||
telefono: t(f.telefono),
|
||||
email: t(f.email),
|
||||
dni: t(f.dni),
|
||||
direccion: t(f.direccion),
|
||||
pais: t(f.pais),
|
||||
contexto: t(f.contexto),
|
||||
relaciones: [],
|
||||
notas: t(f.notas),
|
||||
};
|
||||
}
|
||||
|
||||
export function ContactsView() {
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
@@ -37,23 +119,31 @@ export function ContactsView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debQuery] = useDebouncedValue(query, 200);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
|
||||
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [editSlug, setEditSlug] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
fetchContacts()
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
if (d.status !== "ok") {
|
||||
setError(d.error || "Xandikos no respondió");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setContacts(d.contacts ?? []);
|
||||
})
|
||||
.catch((e) => alive && setError(String(e)))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -74,115 +164,345 @@ export function ContactsView() {
|
||||
});
|
||||
}, [contacts, debQuery]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||
{error}
|
||||
<Text size="sm" mt="xs" c="dimmed">
|
||||
El calendario y los contactos vienen del servidor Xandikos. El resto
|
||||
de la app (grafo, tablas) funciona sin él.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
function openNew() {
|
||||
setForm(EMPTY_FORM);
|
||||
setEditSlug(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(c: Contact) {
|
||||
setForm(formFromContact(c));
|
||||
setEditSlug(c.uid || slugify(c.nombre ?? ""));
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.nombre.trim()) {
|
||||
notifications.show({ color: "red", message: "El nombre es obligatorio" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = formToInput(form);
|
||||
if (editSlug) {
|
||||
await updateContact(editSlug, input);
|
||||
notifications.show({ color: "teal", message: "Contacto actualizado" });
|
||||
} else {
|
||||
const res = await createContact(input);
|
||||
notifications.show({
|
||||
color: "teal",
|
||||
message: `Contacto creado (${res.slug})`,
|
||||
});
|
||||
}
|
||||
setFormOpen(false);
|
||||
setSelected(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", title: "No se pudo guardar", message: String(e) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(c: Contact) {
|
||||
const slug = c.uid || slugify(c.nombre ?? "");
|
||||
if (!confirm(`¿Borrar el contacto "${c.nombre || slug}"? Se elimina la ficha y el contacto del móvil.`))
|
||||
return;
|
||||
try {
|
||||
await deleteContact(slug);
|
||||
notifications.show({ color: "teal", message: "Contacto borrado" });
|
||||
setSelected(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", title: "No se pudo borrar", message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||
<Paper
|
||||
w={360}
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
|
||||
>
|
||||
<Stack gap={0} w="100%">
|
||||
<Box p="md" pb="xs">
|
||||
<TextInput
|
||||
placeholder="Buscar contacto…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{filtered.length} de {contacts.length} contactos
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={0}>
|
||||
{filtered.map((c, i) => {
|
||||
const key = c.uid || c.href || String(i);
|
||||
const isSel = selected === c;
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
px="md"
|
||||
py="xs"
|
||||
onClick={() => setSelected(c)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isSel
|
||||
? "var(--mantine-color-brand-light)"
|
||||
: undefined,
|
||||
borderBottom: "1px solid var(--mantine-color-dark-5)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={isSel ? 600 : 400} truncate>
|
||||
{c.nombre || c.alias || c.uid || "(sin nombre)"}
|
||||
</Text>
|
||||
{(c.telefonos?.[0] || c.correos?.[0]) && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{c.telefonos?.[0] || c.correos?.[0]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<>
|
||||
<ContactForm
|
||||
opened={formOpen}
|
||||
editing={editSlug !== null}
|
||||
form={form}
|
||||
saving={saving}
|
||||
onChange={setForm}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<ScrollArea h="100%">
|
||||
{selected ? (
|
||||
<ContactDetail contact={selected} />
|
||||
) : (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconUser size={48} opacity={0.3} />
|
||||
<Text c="dimmed">Selecciona un contacto</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
{error ? (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||
{error}
|
||||
<Text size="sm" mt="xs" c="dimmed">
|
||||
El calendario y los contactos vienen del servidor Xandikos. El
|
||||
resto de la app (grafo, tablas) funciona sin él.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Center>
|
||||
) : (
|
||||
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
|
||||
<Paper
|
||||
w={360}
|
||||
radius={0}
|
||||
withBorder
|
||||
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, display: "flex" }}
|
||||
>
|
||||
<Stack gap={0} w="100%">
|
||||
<Box p="md" pb="xs">
|
||||
<Button
|
||||
fullWidth
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={openNew}
|
||||
mb="sm"
|
||||
>
|
||||
Nuevo contacto
|
||||
</Button>
|
||||
<TextInput
|
||||
placeholder="Buscar contacto…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{filtered.length} de {contacts.length} contactos
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={0}>
|
||||
{filtered.map((c, i) => {
|
||||
const key = c.uid || c.href || String(i);
|
||||
const isSel = selected === c;
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
px="md"
|
||||
py="xs"
|
||||
onClick={() => setSelected(c)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isSel
|
||||
? "var(--mantine-color-brand-light)"
|
||||
: undefined,
|
||||
borderBottom: "1px solid var(--mantine-color-dark-5)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={isSel ? 600 : 400} truncate>
|
||||
{c.nombre || c.alias || c.uid || "(sin nombre)"}
|
||||
</Text>
|
||||
{(c.telefonos?.[0] || c.correos?.[0]) && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{c.telefonos?.[0] || c.correos?.[0]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<ScrollArea h="100%">
|
||||
{selected ? (
|
||||
<ContactDetail
|
||||
contact={selected}
|
||||
onEdit={() => openEdit(selected)}
|
||||
onDelete={() => onDelete(selected)}
|
||||
/>
|
||||
) : (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconUser size={48} opacity={0.3} />
|
||||
<Text c="dimmed">Selecciona un contacto</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactDetail({ contact }: { contact: Contact }) {
|
||||
function ContactForm({
|
||||
opened,
|
||||
editing,
|
||||
form,
|
||||
saving,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
opened: boolean;
|
||||
editing: boolean;
|
||||
form: FormState;
|
||||
saving: boolean;
|
||||
onChange: (f: FormState) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
onChange({ ...form, [key]: value });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={editing ? "Editar contacto" : "Nuevo contacto"}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Tipo"
|
||||
data={[
|
||||
{ value: "persona", label: "Persona" },
|
||||
{ value: "organizacion", label: "Organización" },
|
||||
]}
|
||||
value={form.tipo}
|
||||
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
|
||||
disabled={editing}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
required
|
||||
value={form.nombre}
|
||||
onChange={(e) => set("nombre", e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<TagsInput
|
||||
label="Aliases"
|
||||
placeholder="otros nombres"
|
||||
value={form.aliases}
|
||||
onChange={(v) => set("aliases", v)}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Teléfono"
|
||||
value={form.telefono}
|
||||
onChange={(e) => set("telefono", e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
{form.tipo === "persona" && (
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="DNI"
|
||||
value={form.dni}
|
||||
onChange={(e) => set("dni", e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="País"
|
||||
value={form.pais}
|
||||
onChange={(e) => set("pais", e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{form.tipo === "organizacion" && (
|
||||
<TextInput
|
||||
label="País"
|
||||
value={form.pais}
|
||||
onChange={(e) => set("pais", e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Dirección"
|
||||
value={form.direccion}
|
||||
onChange={(e) => set("direccion", e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Contexto"
|
||||
placeholder="origen / círculo"
|
||||
data={[
|
||||
"familia",
|
||||
"aurgiobsidian",
|
||||
"google-contacts",
|
||||
"trabajo",
|
||||
"prueba",
|
||||
]}
|
||||
value={form.contexto || null}
|
||||
onChange={(v) => set("contexto", v || "")}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Textarea
|
||||
label="Notas"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.notas}
|
||||
onChange={(e) => set("notas", e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={onSave} loading={saving}>
|
||||
{editing ? "Guardar cambios" : "Crear contacto"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactDetail({
|
||||
contact,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
contact: Contact;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
||||
([, v]) => v != null && v !== "",
|
||||
);
|
||||
return (
|
||||
<Stack p="xl" gap="lg" maw={720}>
|
||||
<Group gap="sm">
|
||||
<Title order={3}>
|
||||
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
|
||||
</Title>
|
||||
{contact.alias && contact.alias !== contact.nombre && (
|
||||
<Badge variant="light" color="gray">
|
||||
{contact.alias}
|
||||
</Badge>
|
||||
)}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="sm">
|
||||
<Title order={3}>
|
||||
{contact.nombre || contact.alias || contact.uid || "(sin nombre)"}
|
||||
</Title>
|
||||
{contact.alias && contact.alias !== contact.nombre && (
|
||||
<Badge variant="light" color="gray">
|
||||
{contact.alias}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Borrar
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{contact.org && (
|
||||
|
||||
Reference in New Issue
Block a user